PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.5.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.5.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
suredonation / inc / database / tables / donations.php

donations.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.5.0, at inc/database/tables/donations.php

2,570 lines 80.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureDonation Database Donations Table Class.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Database\Tables;
9
10 use SureDonation\Inc\Campaigns\Campaign_Stats;
11 use SureDonation\Inc\Database\Base;
12 use SureDonation\Inc\Helper;
13 use SureDonation\Inc\Traits\Get_Instance;
14
15 // Exit if accessed directly.
16 defined( 'ABSPATH' ) || exit;
17
18 /**
19 * SureDonation Database Donations Table Class.
20 *
21 * @since 0.0.1
22 */
23 class Donations extends Base {
24 use Get_Instance;
25
26 /**
27 * Table suffix.
28 *
29 * @var string
30 * @since 0.0.1
31 */
32 protected $table_suffix = 'donations';
33
34 /**
35 * Table version.
36 *
37 * @var int
38 * @since 0.0.1
39 */
40 protected $table_version = 5;
41
42 /**
43 * Valid payment statuses.
44 *
45 * @var array<string>
46 * @since 0.0.1
47 */
48 private static $valid_statuses = [
49 'pending',
50 'processing',
51 'completed',
52 'failed',
53 'refunded',
54 'partially_refunded',
55 'cancelled',
56 'suspicious',
57 ];
58
59 /**
60 * Valid order columns.
61 *
62 * @var array<string>
63 * @since 0.0.1
64 */
65 private static $valid_order_columns = [
66 'id',
67 'campaign_id',
68 'amount',
69 'created_at',
70 'updated_at',
71 'payment_status',
72 'donor_name',
73 'donor_email',
74 'subscription_status',
75 'subscription_id',
76 ];
77
78 /**
79 * {@inheritDoc}
80 */
81 public function get_schema() {
82 return [
83 'id' => [
84 'type' => 'number',
85 ],
86 'campaign_id' => [
87 'type' => 'number',
88 ],
89 'donor_id' => [
90 'type' => 'number',
91 'default' => 0,
92 ],
93 'form_id' => [
94 'type' => 'number',
95 'default' => 0,
96 ],
97 'amount' => [
98 'type' => 'string',
99 'default' => '0.00000000',
100 ],
101 'fees_covered' => [
102 'type' => 'string',
103 'default' => '0.00000000',
104 ],
105 'refunded_amount' => [
106 'type' => 'string',
107 'default' => '0.00000000',
108 ],
109 'currency' => [
110 'type' => 'string',
111 'default' => 'USD',
112 ],
113 'transaction_id' => [
114 'type' => 'string',
115 'default' => '',
116 ],
117 'customer_id' => [
118 'type' => 'string',
119 'default' => '',
120 ],
121 'stripe_account_id' => [
122 'type' => 'string',
123 'default' => '',
124 ],
125 'gateway' => [
126 'type' => 'string',
127 'default' => 'stripe',
128 ],
129 'payment_status' => [
130 'type' => 'string',
131 'default' => 'pending',
132 ],
133 'payment_mode' => [
134 'type' => 'string',
135 'default' => 'test',
136 ],
137 'donor_name' => [
138 'type' => 'string',
139 'default' => '',
140 ],
141 'donor_email' => [
142 'type' => 'string',
143 'default' => '',
144 ],
145 'donor_phone' => [
146 'type' => 'string',
147 'default' => '',
148 ],
149 'is_anonymous' => [
150 'type' => 'boolean',
151 'default' => false,
152 ],
153 'donation_type' => [
154 'type' => 'string',
155 'default' => 'one-time',
156 ],
157 'subscription_id' => [
158 'type' => 'string',
159 'default' => '',
160 ],
161 'subscription_status' => [
162 'type' => 'string',
163 'default' => '',
164 ],
165 'parent_subscription_id' => [
166 'type' => 'number',
167 'default' => 0,
168 ],
169 'donor_comment' => [
170 'type' => 'string',
171 'default' => '',
172 ],
173 'receipt_sent' => [
174 'type' => 'boolean',
175 'default' => false,
176 ],
177 'receipt_pdf_url' => [
178 'type' => 'string',
179 'default' => '',
180 ],
181 'donation_data' => [
182 'type' => 'array',
183 'default' => [],
184 ],
185 'log' => [
186 'type' => 'array',
187 'default' => [],
188 ],
189 'ip_address' => [
190 'type' => 'string',
191 'default' => '',
192 ],
193 'user_agent' => [
194 'type' => 'string',
195 'default' => '',
196 ],
197 'referer_url' => [
198 'type' => 'string',
199 'default' => '',
200 ],
201 'import_source_id' => [
202 'type' => 'number',
203 'default' => 0,
204 ],
205 'import_source' => [
206 'type' => 'string',
207 'default' => '',
208 ],
209 'created_at' => [
210 'type' => 'datetime',
211 ],
212 'updated_at' => [
213 'type' => 'datetime',
214 ],
215 ];
216 }
217
218 /**
219 * {@inheritDoc}
220 */
221 public function get_columns_definition() {
222 return [
223 'id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY',
224 'campaign_id BIGINT(20) UNSIGNED NOT NULL',
225 'donor_id BIGINT(20) UNSIGNED NULL',
226 'form_id BIGINT(20) UNSIGNED NULL',
227 'amount DECIMAL(26,8) NOT NULL',
228 'fees_covered DECIMAL(26,8) NOT NULL DEFAULT 0',
229 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0',
230 'currency VARCHAR(10) NOT NULL',
231 'transaction_id VARCHAR(255) NOT NULL',
232 'customer_id VARCHAR(50) NOT NULL',
233 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\'',
234 'gateway VARCHAR(20) NOT NULL',
235 'payment_status VARCHAR(50) NOT NULL',
236 'payment_mode VARCHAR(20) NOT NULL',
237 'donor_name VARCHAR(255) NOT NULL',
238 'donor_email VARCHAR(255) NOT NULL',
239 'donor_phone VARCHAR(50) NOT NULL',
240 'is_anonymous TINYINT(1) NOT NULL DEFAULT 0',
241 'donation_type VARCHAR(30) NOT NULL',
242 'subscription_id VARCHAR(255) NOT NULL',
243 'subscription_status VARCHAR(30) NOT NULL',
244 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
245 'donor_comment TEXT',
246 'receipt_sent TINYINT(1) NOT NULL DEFAULT 0',
247 'receipt_pdf_url VARCHAR(255) NOT NULL',
248 'donation_data LONGTEXT',
249 'log LONGTEXT',
250 'ip_address VARCHAR(45) NOT NULL',
251 'user_agent TEXT',
252 'referer_url TEXT',
253 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
254 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'',
255 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
256 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
257 'INDEX idx_campaign (campaign_id)',
258 'INDEX idx_donor (donor_id)',
259 'INDEX idx_status (payment_status)',
260 'INDEX idx_email (donor_email)',
261 'INDEX idx_created (created_at)',
262 'INDEX idx_form (form_id)',
263 'INDEX idx_subscription (subscription_id)',
264 'INDEX idx_subscription_status (subscription_status)',
265 'INDEX idx_parent_subscription (parent_subscription_id)',
266 'INDEX idx_import_source (import_source_id, import_source)',
267 'INDEX idx_stripe_account (stripe_account_id)',
268 ];
269 }
270
271 /**
272 * New columns added across versions.
273 *
274 * Version 2 added subscription support; version 4 added the
275 * 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).
279 *
280 * {@inheritDoc}
281 *
282 * @since 1.0.0
283 */
284 public function get_new_columns_definition() {
285 return [
286 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type',
287 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id',
288 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status',
289 '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',
292 'INDEX idx_subscription (subscription_id)',
293 'INDEX idx_subscription_status (subscription_status)',
294 'INDEX idx_parent_subscription (parent_subscription_id)',
295 'INDEX idx_import_source (import_source_id, import_source)',
296 'INDEX idx_stripe_account (stripe_account_id)',
297 ];
298 }
299
300 /**
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 * Add a new donation record.
359 *
360 * @param array<mixed> $data Donation data to insert.
361 * @return int|false The donation ID on success, false on error.
362 * @since 0.0.1
363 */
364 public static function add( $data ) {
365 // Use isset check — empty() would reject campaign_id=0 which is valid for standalone forms.
366 if ( ! isset( $data['campaign_id'] ) ) {
367 return false;
368 }
369
370 $instance = self::get_instance();
371
372 // Set created_at if not provided (use GMT for consistency with TIMESTAMP column default).
373 if ( ! isset( $data['created_at'] ) ) {
374 $data['created_at'] = current_time( 'mysql', true );
375 }
376
377 $result = $instance->use_insert( $data );
378
379 if ( $result ) {
380 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $data['campaign_id'] ) ) );
381
382 // Notify integration hooks (e.g. OttoKit) about the new donation.
383 // Imported rows carry an import_source and are skipped: migrating
384 // historical donations must not replay automations.
385 if ( empty( $data['import_source'] ) ) {
386 $donation_id = absint( $result );
387 $donation = self::get( $donation_id );
388 $donation = is_array( $donation ) ? $donation : [];
389
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 /**
396 * Fires when a new donation record is created.
397 *
398 * @param int $donation_id Newly created donation ID.
399 * @param array<mixed> $donation Curated donation payload.
400 * @since 1.1.0
401 */
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 }
430 }
431 }
432
433 return $result;
434 }
435
436 /**
437 * Update a donation record.
438 *
439 * @param int $donation_id Donation ID to update.
440 * @param array<string,mixed> $data Data to update.
441 * @return int|false Number of rows updated or false on error.
442 * @since 0.0.1
443 */
444 public static function update( $donation_id, $data = [] ) {
445 if ( empty( $donation_id ) ) {
446 return false;
447 }
448
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;
458 }
459
460 // Set updated_at.
461 $data['updated_at'] = current_time( 'mysql' );
462
463 $updated = self::get_instance()->use_update( $data, [ 'id' => absint( $donation_id ) ] );
464
465 // Status/amount changes (e.g. a webhook completing a pending donation)
466 // affect the cached stats and donor lists.
467 if ( $updated ) {
468 $donation = self::get( absint( $donation_id ) );
469 $donation = is_array( $donation ) ? $donation : [];
470 if ( ! empty( $donation['campaign_id'] ) ) {
471 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) );
472 }
473
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
479 if ( isset( $data['payment_status'] ) ) {
480 $new_status = Helper::get_string_value( $data['payment_status'] );
481
482 if ( $new_status !== $old_status ) {
483 /**
484 * Fires when a donation's payment status changes.
485 *
486 * @param int $donation_id Donation ID.
487 * @param string $new_status New payment status.
488 * @param string $old_status Previous payment status (empty string if unknown).
489 * @param array<mixed> $donation Curated donation payload after the update.
490 * @since 1.1.0
491 */
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 }
509 }
510 }
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 }
533
534 return $updated;
535 }
536
537 /**
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 * Get a single donation by ID.
612 *
613 * @param int $donation_id Donation ID.
614 * @return array<mixed>|null Donation data or null if not found.
615 * @since 0.0.1
616 */
617 public static function get( $donation_id ) {
618 if ( empty( $donation_id ) ) {
619 return null;
620 }
621
622 $instance = self::get_instance();
623 global $wpdb;
624
625 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
626 $result = $wpdb->get_row(
627 $wpdb->prepare(
628 'SELECT * FROM %i WHERE id = %d',
629 $instance->get_tablename(),
630 absint( $donation_id )
631 ),
632 ARRAY_A
633 );
634
635 if ( ! $result ) {
636 return null;
637 }
638
639 return $instance->decode_by_datatype( $result );
640 }
641
642 /**
643 * Get all donations with pagination.
644 *
645 * @param int $limit Number of records to return.
646 * @param int $offset Offset for pagination.
647 * @param string $orderby Column to order by.
648 * @param string $order Order direction (ASC or DESC).
649 * @return array<mixed> Array of donations.
650 * @since 0.0.1
651 */
652 public static function get_all( $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
653 $instance = self::get_instance();
654 global $wpdb;
655 $table = $instance->get_tablename();
656
657 // Validate orderby column.
658 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
659 $orderby = 'created_at';
660 }
661
662 // Validate order direction.
663 $order = strtoupper( $order );
664 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
665 $order = 'DESC';
666 }
667
668 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
669 $results = 'ASC' === $order
670 ? $wpdb->get_results(
671 $wpdb->prepare(
672 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
673 $table,
674 $orderby,
675 absint( $offset ),
676 absint( $limit )
677 ),
678 ARRAY_A
679 )
680 : $wpdb->get_results(
681 $wpdb->prepare(
682 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
683 $table,
684 $orderby,
685 absint( $offset ),
686 absint( $limit )
687 ),
688 ARRAY_A
689 );
690 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
691
692 if ( ! $results || ! is_array( $results ) ) {
693 return [];
694 }
695
696 return array_map( [ $instance, 'decode_by_datatype' ], $results );
697 }
698
699 /**
700 * Get donations for admin listing with optional filters.
701 *
702 * @param string $status Payment status filter ('all' for no filter).
703 * @param int $campaign_id Campaign ID filter (0 for no filter).
704 * @param string $search Search term for donor_name, donor_email, or transaction_id.
705 * @param int $limit Number of records to return.
706 * @param int $offset Offset for pagination.
707 * @param string $orderby Column to order by.
708 * @param string $order Order direction (ASC or DESC).
709 * @return array<mixed> Array of donations.
710 * @since 0.0.1
711 */
712 public static function get_admin_list( $status = 'all', $campaign_id = 0, $search = '', $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
713 $instance = self::get_instance();
714 global $wpdb;
715 $table = $instance->get_tablename();
716
717 // Validate orderby column.
718 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
719 $orderby = 'created_at';
720 }
721
722 // Validate order direction.
723 $order = strtoupper( $order );
724 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
725 $order = 'DESC';
726 }
727
728 // Build query based on filters.
729 // Note: Renewal records (donation_type = 'renewal') are intentionally included in the listing.
730 // They are shown alongside parent subscriptions so admins can see all transaction activity.
731 // Renewals are also accessible from the parent donation's subscription detail billing history.
732 $has_status = 'all' !== $status;
733 $has_campaign = $campaign_id > 0;
734 $has_search = ! empty( $search );
735 $is_asc = 'ASC' === $order;
736
737 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
738
739 // All three filters.
740 if ( $has_status && $has_campaign && $has_search ) {
741 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
742 $results = $is_asc
743 ? $wpdb->get_results(
744 $wpdb->prepare(
745 'SELECT * FROM %i WHERE payment_status = %s AND 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',
746 $table,
747 sanitize_text_field( $status ),
748 absint( $campaign_id ),
749 $search_term,
750 $search_term,
751 $search_term,
752 $orderby,
753 absint( $offset ),
754 absint( $limit )
755 ),
756 ARRAY_A
757 )
758 : $wpdb->get_results(
759 $wpdb->prepare(
760 'SELECT * FROM %i WHERE payment_status = %s AND 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',
761 $table,
762 sanitize_text_field( $status ),
763 absint( $campaign_id ),
764 $search_term,
765 $search_term,
766 $search_term,
767 $orderby,
768 absint( $offset ),
769 absint( $limit )
770 ),
771 ARRAY_A
772 );
773 } elseif ( $has_status && $has_campaign ) {
774 $results = $is_asc
775 ? $wpdb->get_results(
776 $wpdb->prepare(
777 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
778 $table,
779 sanitize_text_field( $status ),
780 absint( $campaign_id ),
781 $orderby,
782 absint( $offset ),
783 absint( $limit )
784 ),
785 ARRAY_A
786 )
787 : $wpdb->get_results(
788 $wpdb->prepare(
789 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
790 $table,
791 sanitize_text_field( $status ),
792 absint( $campaign_id ),
793 $orderby,
794 absint( $offset ),
795 absint( $limit )
796 ),
797 ARRAY_A
798 );
799 } elseif ( $has_status && $has_search ) {
800 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
801 $results = $is_asc
802 ? $wpdb->get_results(
803 $wpdb->prepare(
804 'SELECT * FROM %i WHERE payment_status = %s AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
805 $table,
806 sanitize_text_field( $status ),
807 $search_term,
808 $search_term,
809 $search_term,
810 $orderby,
811 absint( $offset ),
812 absint( $limit )
813 ),
814 ARRAY_A
815 )
816 : $wpdb->get_results(
817 $wpdb->prepare(
818 'SELECT * FROM %i WHERE payment_status = %s AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
819 $table,
820 sanitize_text_field( $status ),
821 $search_term,
822 $search_term,
823 $search_term,
824 $orderby,
825 absint( $offset ),
826 absint( $limit )
827 ),
828 ARRAY_A
829 );
830 } elseif ( $has_campaign && $has_search ) {
831 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
832 $results = $is_asc
833 ? $wpdb->get_results(
834 $wpdb->prepare(
835 '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',
836 $table,
837 absint( $campaign_id ),
838 $search_term,
839 $search_term,
840 $search_term,
841 $orderby,
842 absint( $offset ),
843 absint( $limit )
844 ),
845 ARRAY_A
846 )
847 : $wpdb->get_results(
848 $wpdb->prepare(
849 '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',
850 $table,
851 absint( $campaign_id ),
852 $search_term,
853 $search_term,
854 $search_term,
855 $orderby,
856 absint( $offset ),
857 absint( $limit )
858 ),
859 ARRAY_A
860 );
861 } elseif ( $has_status ) {
862 $results = $is_asc
863 ? $wpdb->get_results(
864 $wpdb->prepare(
865 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
866 $table,
867 sanitize_text_field( $status ),
868 $orderby,
869 absint( $offset ),
870 absint( $limit )
871 ),
872 ARRAY_A
873 )
874 : $wpdb->get_results(
875 $wpdb->prepare(
876 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
877 $table,
878 sanitize_text_field( $status ),
879 $orderby,
880 absint( $offset ),
881 absint( $limit )
882 ),
883 ARRAY_A
884 );
885 } elseif ( $has_campaign ) {
886 $results = $is_asc
887 ? $wpdb->get_results(
888 $wpdb->prepare(
889 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
890 $table,
891 absint( $campaign_id ),
892 $orderby,
893 absint( $offset ),
894 absint( $limit )
895 ),
896 ARRAY_A
897 )
898 : $wpdb->get_results(
899 $wpdb->prepare(
900 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
901 $table,
902 absint( $campaign_id ),
903 $orderby,
904 absint( $offset ),
905 absint( $limit )
906 ),
907 ARRAY_A
908 );
909 } elseif ( $has_search ) {
910 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
911 $results = $is_asc
912 ? $wpdb->get_results(
913 $wpdb->prepare(
914 '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',
915 $table,
916 $search_term,
917 $search_term,
918 $search_term,
919 $orderby,
920 absint( $offset ),
921 absint( $limit )
922 ),
923 ARRAY_A
924 )
925 : $wpdb->get_results(
926 $wpdb->prepare(
927 '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',
928 $table,
929 $search_term,
930 $search_term,
931 $search_term,
932 $orderby,
933 absint( $offset ),
934 absint( $limit )
935 ),
936 ARRAY_A
937 );
938 } else {
939 $results = $is_asc
940 ? $wpdb->get_results(
941 $wpdb->prepare(
942 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
943 $table,
944 $orderby,
945 absint( $offset ),
946 absint( $limit )
947 ),
948 ARRAY_A
949 )
950 : $wpdb->get_results(
951 $wpdb->prepare(
952 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
953 $table,
954 $orderby,
955 absint( $offset ),
956 absint( $limit )
957 ),
958 ARRAY_A
959 );
960 }
961
962 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
963
964 if ( ! $results || ! is_array( $results ) ) {
965 return [];
966 }
967
968 return array_map( [ $instance, 'decode_by_datatype' ], $results );
969 }
970
971 /**
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 * Get donations by status with pagination.
1112 *
1113 * @param string $status Payment status.
1114 * @param int $limit Number of records to return.
1115 * @param int $offset Offset for pagination.
1116 * @param string $orderby Column to order by.
1117 * @param string $order Order direction (ASC or DESC).
1118 * @return array<mixed> Array of donations.
1119 * @since 0.0.1
1120 */
1121 public static function get_by_status( $status, $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
1122 $instance = self::get_instance();
1123 global $wpdb;
1124 $table = $instance->get_tablename();
1125
1126 // Validate orderby column.
1127 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
1128 $orderby = 'created_at';
1129 }
1130
1131 // Validate order direction.
1132 $order = strtoupper( $order );
1133 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
1134 $order = 'DESC';
1135 }
1136
1137 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
1138 $results = 'ASC' === $order
1139 ? $wpdb->get_results(
1140 $wpdb->prepare(
1141 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
1142 $table,
1143 sanitize_text_field( $status ),
1144 $orderby,
1145 absint( $offset ),
1146 absint( $limit )
1147 ),
1148 ARRAY_A
1149 )
1150 : $wpdb->get_results(
1151 $wpdb->prepare(
1152 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
1153 $table,
1154 sanitize_text_field( $status ),
1155 $orderby,
1156 absint( $offset ),
1157 absint( $limit )
1158 ),
1159 ARRAY_A
1160 );
1161 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1162
1163 if ( ! $results || ! is_array( $results ) ) {
1164 return [];
1165 }
1166
1167 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1168 }
1169
1170 /**
1171 * Get donations by campaign ID with pagination.
1172 *
1173 * @param int $campaign_id Campaign ID.
1174 * @param int $limit Number of records to return.
1175 * @param int $offset Offset for pagination.
1176 * @param string $orderby Column to order by.
1177 * @param string $order Order direction (ASC or DESC).
1178 * @return array<mixed> Array of donations.
1179 * @since 0.0.1
1180 */
1181 public static function get_by_campaign_id( $campaign_id, $limit = 100, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
1182 if ( empty( $campaign_id ) ) {
1183 return [];
1184 }
1185
1186 $instance = self::get_instance();
1187 global $wpdb;
1188 $table = $instance->get_tablename();
1189
1190 // Validate orderby column.
1191 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
1192 $orderby = 'created_at';
1193 }
1194
1195 // Validate order direction.
1196 $order = strtoupper( $order );
1197 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
1198 $order = 'DESC';
1199 }
1200
1201 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
1202 $results = 'ASC' === $order
1203 ? $wpdb->get_results(
1204 $wpdb->prepare(
1205 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
1206 $table,
1207 absint( $campaign_id ),
1208 $orderby,
1209 absint( $offset ),
1210 absint( $limit )
1211 ),
1212 ARRAY_A
1213 )
1214 : $wpdb->get_results(
1215 $wpdb->prepare(
1216 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
1217 $table,
1218 absint( $campaign_id ),
1219 $orderby,
1220 absint( $offset ),
1221 absint( $limit )
1222 ),
1223 ARRAY_A
1224 );
1225 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1226
1227 if ( ! $results || ! is_array( $results ) ) {
1228 return [];
1229 }
1230
1231 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1232 }
1233
1234 /**
1235 * Delete a donation record.
1236 *
1237 * @param int $donation_id Donation ID.
1238 * @return int|false Number of rows deleted or false on error.
1239 * @since 0.0.1
1240 */
1241 public static function delete( $donation_id ) {
1242 if ( empty( $donation_id ) ) {
1243 return false;
1244 }
1245
1246 return self::get_instance()->use_delete( [ 'id' => absint( $donation_id ) ] );
1247 }
1248
1249 /**
1250 * Get donations by donor email.
1251 *
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.
1255 * @return array<mixed> Array of donations.
1256 * @since 0.0.1
1257 */
1258 public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) {
1259 if ( empty( $email ) ) {
1260 return [];
1261 }
1262
1263 $instance = self::get_instance();
1264 global $wpdb;
1265
1266 $limit = max( 0, (int) $limit );
1267 $offset = max( 0, (int) $offset );
1268
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 if ( ! $results || ! is_array( $results ) ) {
1294 return [];
1295 }
1296
1297 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1298 }
1299
1300 /**
1301 * Get donation by transaction ID.
1302 *
1303 * @param string $transaction_id Transaction ID.
1304 * @return array<string, mixed>|null Donation data or null if not found.
1305 * @since 0.0.1
1306 */
1307 public static function get_by_transaction_id( $transaction_id ) {
1308 if ( empty( $transaction_id ) ) {
1309 return null;
1310 }
1311
1312 $instance = self::get_instance();
1313 global $wpdb;
1314
1315 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1316 $result = $wpdb->get_row(
1317 $wpdb->prepare(
1318 'SELECT * FROM %i WHERE transaction_id = %s LIMIT 1',
1319 $instance->get_tablename(),
1320 sanitize_text_field( $transaction_id )
1321 ),
1322 ARRAY_A
1323 );
1324
1325 if ( ! $result ) {
1326 return null;
1327 }
1328
1329 return $instance->decode_by_datatype( $result );
1330 }
1331
1332 /**
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 * Get total donations count (no filters).
1376 *
1377 * @return int Total count.
1378 * @since 0.0.1
1379 */
1380 public static function count_all() {
1381 $instance = self::get_instance();
1382 global $wpdb;
1383
1384 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1385 $count = $wpdb->get_var(
1386 $wpdb->prepare(
1387 'SELECT COUNT(*) FROM %i',
1388 $instance->get_tablename()
1389 )
1390 );
1391
1392 return is_numeric( $count ) ? (int) $count : 0;
1393 }
1394
1395 /**
1396 * Get total donations count by payment status.
1397 *
1398 * @param string $status Payment status.
1399 * @return int Total count.
1400 * @since 0.0.1
1401 */
1402 public static function count_by_status( $status ) {
1403 $instance = self::get_instance();
1404 global $wpdb;
1405
1406 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1407 $count = $wpdb->get_var(
1408 $wpdb->prepare(
1409 'SELECT COUNT(*) FROM %i WHERE payment_status = %s',
1410 $instance->get_tablename(),
1411 sanitize_text_field( $status )
1412 )
1413 );
1414
1415 return is_numeric( $count ) ? (int) $count : 0;
1416 }
1417
1418 /**
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 * Get total donations count by campaign.
1446 *
1447 * @param int $campaign_id Campaign ID.
1448 * @return int Total count.
1449 * @since 0.0.1
1450 */
1451 public static function count_by_campaign( $campaign_id ) {
1452 $instance = self::get_instance();
1453 global $wpdb;
1454
1455 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1456 $count = $wpdb->get_var(
1457 $wpdb->prepare(
1458 'SELECT COUNT(*) FROM %i WHERE campaign_id = %d',
1459 $instance->get_tablename(),
1460 absint( $campaign_id )
1461 )
1462 );
1463
1464 return is_numeric( $count ) ? (int) $count : 0;
1465 }
1466
1467 /**
1468 * Get total donations count by status and campaign.
1469 *
1470 * @param string $status Payment status ('all' for no filter).
1471 * @param int $campaign_id Optional campaign ID (0 for no filter).
1472 * @return int Total count.
1473 * @since 0.0.1
1474 */
1475 public static function get_total_donations_by_status( $status = 'all', $campaign_id = 0 ) {
1476 $instance = self::get_instance();
1477 global $wpdb;
1478
1479 // Both filters.
1480 if ( 'all' !== $status && $campaign_id > 0 ) {
1481 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1482 $count = $wpdb->get_var(
1483 $wpdb->prepare(
1484 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND campaign_id = %d',
1485 $instance->get_tablename(),
1486 sanitize_text_field( $status ),
1487 absint( $campaign_id )
1488 )
1489 );
1490 return is_numeric( $count ) ? (int) $count : 0;
1491 }
1492
1493 // Status filter only.
1494 if ( 'all' !== $status ) {
1495 return self::count_by_status( $status );
1496 }
1497
1498 // Campaign filter only.
1499 if ( $campaign_id > 0 ) {
1500 return self::count_by_campaign( $campaign_id );
1501 }
1502
1503 // No filters.
1504 return self::count_all();
1505 }
1506
1507 /**
1508 * Get campaign statistics.
1509 *
1510 * @param int $campaign_id Campaign ID.
1511 * @return array<string,mixed> Campaign statistics.
1512 * @since 0.0.1
1513 */
1514 public static function get_campaign_stats( $campaign_id ) {
1515 $instance = self::get_instance();
1516 global $wpdb;
1517
1518 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1519 $stats = $wpdb->get_row(
1520 $wpdb->prepare(
1521 "SELECT
1522 COUNT(*) as donation_count,
1523 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1524 COUNT(DISTINCT donor_email) as unique_donors,
1525 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1526 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1527 FROM %i
1528 WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded')",
1529 $instance->get_tablename(),
1530 absint( $campaign_id )
1531 ),
1532 ARRAY_A
1533 );
1534
1535 return $stats ? $stats : [
1536 'donation_count' => 0,
1537 'total_raised' => 0,
1538 'unique_donors' => 0,
1539 'average_donation' => 0,
1540 'largest_donation' => 0,
1541 ];
1542 }
1543
1544 /**
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 * Get global dashboard statistics.
1576 *
1577 * @param string $currency Currency code to scope to ('' for no filter).
1578 * @param string $payment_mode 'test' or 'live' ('' for no filter).
1579 * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics.
1580 * @since 0.0.1
1581 */
1582 public static function get_dashboard_stats( $currency = '', $payment_mode = '' ) {
1583 $instance = self::get_instance();
1584 global $wpdb;
1585
1586 $args = [ $instance->get_tablename() ];
1587 $extra = self::scope_fragment( $currency, $payment_mode, $args );
1588
1589 $sql = "SELECT
1590 COUNT(*) as total_donations,
1591 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1592 COUNT(DISTINCT donor_email) as unique_donors,
1593 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1594 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1595 FROM %i
1596 WHERE payment_status IN ('completed', 'partially_refunded')
1597 {$extra}";
1598
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 return $stats ? $stats : [
1603 'total_donations' => 0,
1604 'total_raised' => 0,
1605 'unique_donors' => 0,
1606 'average_donation' => 0,
1607 'largest_donation' => 0,
1608 ];
1609 }
1610
1611 /**
1612 * Get recent donations globally (all campaigns).
1613 *
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).
1617 * @return array<int, array<string, mixed>> Array of recent donations.
1618 * @since 0.0.1
1619 */
1620 public static function get_recent_donations_global( $limit = 5, $currency = '', $payment_mode = '' ) {
1621 $instance = self::get_instance();
1622 global $wpdb;
1623
1624 $args = [ $instance->get_tablename() ];
1625 $extra = self::scope_fragment( $currency, $payment_mode, $args );
1626 $args[] = absint( $limit );
1627
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 if ( ! $results || ! is_array( $results ) ) {
1638 return [];
1639 }
1640
1641 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1642 }
1643
1644 /**
1645 * Get top campaigns by donations.
1646 *
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).
1650 * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats.
1651 * @since 0.0.1
1652 */
1653 public static function get_top_campaigns( $limit = 5, $currency = '', $payment_mode = '' ) {
1654 $instance = self::get_instance();
1655 global $wpdb;
1656
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,
1668 COUNT(*) as donation_count,
1669 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1670 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
1675 WHERE payment_status IN ('completed', 'partially_refunded')
1676 {$extra}
1677 GROUP BY d.campaign_id
1678 ORDER BY total_raised DESC
1679 LIMIT %d";
1680
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 return $results ? $results : [];
1685 }
1686
1687 /**
1688 * Get donation trends over time.
1689 *
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).
1696 * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends.
1697 * @since 0.0.1
1698 */
1699 public static function get_donation_trends( $after = '', $before = '', $group = 'day', $currency = '', $campaign_id = 0, $payment_mode = '' ) {
1700 $instance = self::get_instance();
1701 global $wpdb;
1702
1703 // Default to last 30 days if no dates provided.
1704 if ( empty( $after ) ) {
1705 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
1706 }
1707 if ( empty( $before ) ) {
1708 $before = gmdate( 'Y-m-d' );
1709 }
1710
1711 // Determine date format based on grouping.
1712 switch ( $group ) {
1713 case 'month':
1714 $date_format = '%Y-%m-01';
1715 break;
1716 case 'week':
1717 $date_format = '%x-%v'; // ISO year-week.
1718 break;
1719 case 'day':
1720 default:
1721 $date_format = '%Y-%m-%d';
1722 break;
1723 }
1724
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
1750 DATE_FORMAT(created_at, %s) as period,
1751 COUNT(*) as donation_count,
1752 COALESCE(SUM(amount - refunded_amount), 0) as total_amount
1753 FROM %i
1754 WHERE payment_status IN ('completed', 'partially_refunded')
1755 AND DATE(created_at) >= %s
1756 AND DATE(created_at) <= %s
1757 {$extra}
1758 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',
1785 $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'
1811 ),
1812 ARRAY_A
1813 );
1814
1815 return [
1816 'entries' => is_array( $result ) ? (int) ( $result['entries'] ?? 0 ) : 0,
1817 'revenue' => is_array( $result ) ? (float) ( $result['revenue'] ?? 0 ) : 0.0,
1818 ];
1819 }
1820
1821 /**
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 * Get recent donations for a campaign.
1947 *
1948 * @param int $campaign_id Campaign ID.
1949 * @param int $limit Number of donations to retrieve.
1950 * @return array<mixed> Array of recent donations.
1951 * @since 0.0.1
1952 */
1953 public static function get_recent_donations( $campaign_id, $limit = 5 ) {
1954 $instance = self::get_instance();
1955 global $wpdb;
1956
1957 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1958 $results = $wpdb->get_results(
1959 $wpdb->prepare(
1960 "SELECT * FROM %i WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
1961 $instance->get_tablename(),
1962 absint( $campaign_id ),
1963 absint( $limit )
1964 ),
1965 ARRAY_A
1966 );
1967
1968 if ( ! $results || ! is_array( $results ) ) {
1969 return [];
1970 }
1971
1972 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1973 }
1974
1975 /**
1976 * Get paginated donations for a specific donor.
1977 *
1978 * @param int $donor_id Donor ID.
1979 * @param int $limit Number of records to return.
1980 * @param int $offset Offset for pagination.
1981 * @return array{donations: array<int, array<string, mixed>>, total: int} Paginated donations and total count.
1982 * @since 1.0.0
1983 */
1984 public static function get_by_donor_id( $donor_id, $limit = 10, $offset = 0 ) {
1985 if ( empty( $donor_id ) ) {
1986 return [
1987 'donations' => [],
1988 'total' => 0,
1989 ];
1990 }
1991
1992 $instance = self::get_instance();
1993 global $wpdb;
1994 $table = $instance->get_tablename();
1995
1996 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1997
1998 $total = $wpdb->get_var(
1999 $wpdb->prepare(
2000 'SELECT COUNT(*) FROM %i WHERE donor_id = %d',
2001 $table,
2002 absint( $donor_id )
2003 )
2004 );
2005
2006 $results = $wpdb->get_results(
2007 $wpdb->prepare(
2008 'SELECT * FROM %i WHERE donor_id = %d ORDER BY created_at DESC LIMIT %d, %d',
2009 $table,
2010 absint( $donor_id ),
2011 absint( $offset ),
2012 absint( $limit )
2013 ),
2014 ARRAY_A
2015 );
2016
2017 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2018
2019 if ( ! $results || ! is_array( $results ) ) {
2020 $results = [];
2021 }
2022
2023 return [
2024 'donations' => array_map( [ $instance, 'decode_by_datatype' ], $results ),
2025 'total' => is_numeric( $total ) ? (int) $total : 0,
2026 ];
2027 }
2028
2029 /**
2030 * Get donation activity data for a specific donor (for chart).
2031 *
2032 * @param int $donor_id Donor ID.
2033 * @param string $after Start date (Y-m-d).
2034 * @param string $before End date (Y-m-d).
2035 * @return array{chart_data: array<int, array{date: string, amount: float}>, stats: array{lifetime: float, highest: float, average: float}} Activity data.
2036 * @since 1.0.0
2037 */
2038 public static function get_donor_activity( $donor_id, $after = '', $before = '' ) {
2039 if ( empty( $donor_id ) ) {
2040 return [
2041 'chart_data' => [],
2042 'stats' => [
2043 'lifetime' => 0,
2044 'highest' => 0,
2045 'average' => 0,
2046 ],
2047 ];
2048 }
2049
2050 $instance = self::get_instance();
2051 global $wpdb;
2052 $table = $instance->get_tablename();
2053
2054 // Default date range: last 30 days.
2055 if ( empty( $after ) ) {
2056 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
2057 }
2058 if ( empty( $before ) ) {
2059 $before = gmdate( 'Y-m-d' );
2060 }
2061
2062 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2063
2064 // Chart data: donations grouped by date.
2065 $chart_data = $wpdb->get_results(
2066 $wpdb->prepare(
2067 "SELECT DATE(created_at) as date, COALESCE(SUM(amount), 0) as amount
2068 FROM %i
2069 WHERE donor_id = %d
2070 AND payment_status IN ('completed', 'partially_refunded')
2071 AND DATE(created_at) >= %s
2072 AND DATE(created_at) <= %s
2073 GROUP BY DATE(created_at)
2074 ORDER BY date ASC",
2075 $table,
2076 absint( $donor_id ),
2077 $after,
2078 $before
2079 ),
2080 ARRAY_A
2081 );
2082
2083 // Lifetime stats for this donor.
2084 $stats = $wpdb->get_row(
2085 $wpdb->prepare(
2086 "SELECT
2087 COALESCE(SUM(amount - refunded_amount), 0) as lifetime,
2088 COALESCE(MAX(amount), 0) as highest,
2089 COALESCE(AVG(amount), 0) as average
2090 FROM %i
2091 WHERE donor_id = %d AND payment_status IN ('completed', 'partially_refunded')",
2092 $table,
2093 absint( $donor_id )
2094 ),
2095 ARRAY_A
2096 );
2097
2098 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2099
2100 $stats = is_array( $stats ) ? $stats : [];
2101
2102 return [
2103 'chart_data' => is_array( $chart_data ) ? $chart_data : [],
2104 'stats' => [
2105 'lifetime' => is_numeric( $stats['lifetime'] ?? 0 ) ? round( (float) ( $stats['lifetime'] ?? 0 ), 2 ) : 0,
2106 'highest' => is_numeric( $stats['highest'] ?? 0 ) ? round( (float) ( $stats['highest'] ?? 0 ), 2 ) : 0,
2107 'average' => is_numeric( $stats['average'] ?? 0 ) ? round( (float) ( $stats['average'] ?? 0 ), 2 ) : 0,
2108 ],
2109 ];
2110 }
2111
2112 /**
2113 * Update donation status.
2114 *
2115 * @param int $donation_id Donation ID.
2116 * @param string $status New status.
2117 * @return int|false Number of rows updated or false on error.
2118 * @since 0.0.1
2119 */
2120 public static function update_status( $donation_id, $status ) {
2121 if ( empty( $donation_id ) || ! in_array( $status, self::$valid_statuses, true ) ) {
2122 return false;
2123 }
2124
2125 return self::update( $donation_id, [ 'payment_status' => $status ] );
2126 }
2127
2128 /**
2129 * Get valid payment statuses.
2130 *
2131 * @return array<string> Valid statuses.
2132 * @since 0.0.1
2133 */
2134 public static function get_valid_statuses() {
2135 return self::$valid_statuses;
2136 }
2137
2138 /**
2139 * Add a log entry to a donation.
2140 *
2141 * @param int $donation_id Donation ID.
2142 * @param string $action Action type (e.g., 'status_change', 'refund', 'webhook').
2143 * @param string $message Log message.
2144 * @param array<string, mixed> $data Optional additional data.
2145 * @return int|false Number of rows updated or false on error.
2146 * @since 0.0.1
2147 */
2148 public static function add_log( $donation_id, $action, $message, $data = [] ) {
2149 if ( empty( $donation_id ) ) {
2150 return false;
2151 }
2152
2153 $donation = self::get( $donation_id );
2154 if ( ! $donation ) {
2155 return false;
2156 }
2157
2158 // Get existing log or initialize empty array.
2159 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
2160 $log_data = $donation['log'] ?? [];
2161 if ( is_array( $log_data ) ) {
2162 $log = $log_data;
2163 } elseif ( is_string( $log_data ) && ! empty( $log_data ) ) {
2164 $log = json_decode( $log_data, true );
2165 if ( ! is_array( $log ) ) {
2166 $log = [];
2167 }
2168 } else {
2169 $log = [];
2170 }
2171
2172 // Add new log entry.
2173 $log[] = [
2174 'action' => sanitize_text_field( $action ),
2175 'message' => sanitize_text_field( $message ),
2176 'data' => $data,
2177 'timestamp' => current_time( 'mysql' ),
2178 ];
2179
2180 return self::update( $donation_id, [ 'log' => $log ] );
2181 }
2182
2183 /**
2184 * Get log entries for a donation.
2185 *
2186 * @param int $donation_id Donation ID.
2187 * @return array<int, array<string, mixed>> Log entries.
2188 * @since 0.0.1
2189 */
2190 public static function get_log( $donation_id ) {
2191 if ( empty( $donation_id ) ) {
2192 return [];
2193 }
2194
2195 $donation = self::get( $donation_id );
2196 if ( ! $donation || empty( $donation['log'] ) ) {
2197 return [];
2198 }
2199
2200 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
2201 $log_data = $donation['log'];
2202 if ( is_array( $log_data ) ) {
2203 return $log_data;
2204 }
2205
2206 if ( is_string( $log_data ) ) {
2207 $log = json_decode( $log_data, true );
2208 return is_array( $log ) ? $log : [];
2209 }
2210
2211 return [];
2212 }
2213
2214 /**
2215 * Add refund data to donation_data for audit trail and duplicate prevention.
2216 *
2217 * Stores each refund with its ID as the key for O(1) lookups.
2218 *
2219 * @param int $donation_id Donation ID.
2220 * @param array<string, mixed> $refund_data Refund data to store.
2221 * @return bool True on success, false on failure.
2222 * @since 0.0.1
2223 */
2224 public static function add_refund_to_donation_data( $donation_id, $refund_data ) {
2225 $refund_id = $refund_data['refund_id'] ?? '';
2226
2227 if ( empty( $refund_id ) || empty( $donation_id ) ) {
2228 return false;
2229 }
2230
2231 $donation = self::get( $donation_id );
2232 if ( ! $donation ) {
2233 return false;
2234 }
2235
2236 // Get existing donation_data.
2237 $donation_data = $donation['donation_data'] ?? [];
2238 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2239 $donation_data = json_decode( $donation_data, true );
2240 }
2241 if ( ! is_array( $donation_data ) ) {
2242 $donation_data = [];
2243 }
2244
2245 // Initialize refunds array if not exists.
2246 if ( ! isset( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
2247 $donation_data['refunds'] = [];
2248 }
2249
2250 // Store with refund ID as key for O(1) lookup (duplicate prevention).
2251 $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
2300 // Update donation_data in database.
2301 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2302
2303 return false !== $result;
2304 }
2305
2306 /**
2307 * Check if a refund already exists in the donation data.
2308 *
2309 * This prevents duplicate processing of the same refund.
2310 *
2311 * @param int $donation_id Donation ID.
2312 * @param string $refund_id Refund ID to check.
2313 * @return bool True if refund already exists, false otherwise.
2314 * @since 0.0.1
2315 */
2316 public static function check_refund_exists( $donation_id, $refund_id ) {
2317 if ( empty( $donation_id ) || empty( $refund_id ) ) {
2318 return false;
2319 }
2320
2321 $donation = self::get( $donation_id );
2322 if ( ! $donation ) {
2323 return false;
2324 }
2325
2326 // Get donation_data and parse if needed.
2327 $donation_data = $donation['donation_data'] ?? [];
2328 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2329 $donation_data = json_decode( $donation_data, true );
2330 }
2331 if ( ! is_array( $donation_data ) ) {
2332 return false;
2333 }
2334
2335 // Check if refunds array exists and contains this refund ID.
2336 if ( empty( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
2337 return false;
2338 }
2339
2340 // O(1) lookup using refund ID as array key.
2341 return isset( $donation_data['refunds'][ $refund_id ] );
2342 }
2343
2344 /**
2345 * Add a note to a donation.
2346 *
2347 * @param int $donation_id Donation ID.
2348 * @param string $note_content Note content.
2349 * @param int $author_id Author user ID.
2350 * @return array{success: bool, note_id: string|null} Result with success status and note ID.
2351 * @since 0.0.1
2352 */
2353 public static function add_note( $donation_id, $note_content, $author_id = 0 ) {
2354 $result = [
2355 'success' => false,
2356 'note_id' => null,
2357 ];
2358
2359 if ( empty( $donation_id ) || empty( $note_content ) ) {
2360 return $result;
2361 }
2362
2363 $donation = self::get( $donation_id );
2364 if ( ! $donation ) {
2365 return $result;
2366 }
2367
2368 // Get existing donation_data.
2369 $donation_data = $donation['donation_data'] ?? [];
2370 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2371 $donation_data = json_decode( $donation_data, true );
2372 }
2373 if ( ! is_array( $donation_data ) ) {
2374 $donation_data = [];
2375 }
2376
2377 // Initialize notes array if not exists.
2378 if ( ! isset( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
2379 $donation_data['notes'] = [];
2380 }
2381
2382 // Generate unique note ID.
2383 $note_id = uniqid( 'note_', true );
2384
2385 // Get author info.
2386 $author_name = __( 'System', 'suredonation' );
2387 if ( $author_id > 0 ) {
2388 $user = get_userdata( $author_id );
2389 if ( $user ) {
2390 $author_name = $user->display_name;
2391 }
2392 }
2393
2394 // Add new note.
2395 $donation_data['notes'][ $note_id ] = [
2396 'id' => $note_id,
2397 'content' => wp_kses_post( $note_content ),
2398 'author_id' => $author_id,
2399 'author_name' => $author_name,
2400 'created_at' => current_time( 'mysql' ),
2401 ];
2402
2403 // Update donation_data in database.
2404 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2405
2406 if ( false !== $update_result ) {
2407 $result['success'] = true;
2408 $result['note_id'] = $note_id;
2409 }
2410
2411 return $result;
2412 }
2413
2414 /**
2415 * Get notes for a donation with pagination.
2416 *
2417 * @param int $donation_id Donation ID.
2418 * @param int $page Current page (1-indexed).
2419 * @param int $per_page Notes per page.
2420 * @return array{notes: array<int, array<string, mixed>>, total: int, total_pages: int} Paginated notes.
2421 * @since 0.0.1
2422 */
2423 public static function get_notes( $donation_id, $page = 1, $per_page = 3 ) {
2424 $result = [
2425 'notes' => [],
2426 'total' => 0,
2427 'total_pages' => 0,
2428 ];
2429
2430 if ( empty( $donation_id ) ) {
2431 return $result;
2432 }
2433
2434 $donation = self::get( $donation_id );
2435 if ( ! $donation ) {
2436 return $result;
2437 }
2438
2439 // Get donation_data and parse if needed.
2440 $donation_data = $donation['donation_data'] ?? [];
2441 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2442 $donation_data = json_decode( $donation_data, true );
2443 }
2444 if ( ! is_array( $donation_data ) ) {
2445 return $result;
2446 }
2447
2448 // Get notes array.
2449 if ( empty( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
2450 return $result;
2451 }
2452
2453 // Convert to array values and sort by created_at (newest first).
2454 $all_notes = array_values( $donation_data['notes'] );
2455 usort(
2456 $all_notes,
2457 static function ( $a, $b ) {
2458 return strtotime( $b['created_at'] ?? '0' ) - strtotime( $a['created_at'] ?? '0' );
2459 }
2460 );
2461
2462 $total = count( $all_notes );
2463 $total_pages = (int) ceil( $total / $per_page );
2464 $offset = ( $page - 1 ) * $per_page;
2465
2466 // Get paginated notes.
2467 $notes = array_slice( $all_notes, $offset, $per_page );
2468
2469 return [
2470 'notes' => $notes,
2471 'total' => $total,
2472 'total_pages' => $total_pages,
2473 ];
2474 }
2475
2476 /**
2477 * Delete a note from a donation.
2478 *
2479 * @param int $donation_id Donation ID.
2480 * @param string $note_id Note ID to delete.
2481 * @return bool True on success, false on failure.
2482 * @since 0.0.1
2483 */
2484 public static function delete_note( $donation_id, $note_id ) {
2485 if ( empty( $donation_id ) || empty( $note_id ) ) {
2486 return false;
2487 }
2488
2489 $donation = self::get( $donation_id );
2490 if ( ! $donation ) {
2491 return false;
2492 }
2493
2494 // Get donation_data and parse if needed.
2495 $donation_data = $donation['donation_data'] ?? [];
2496 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2497 $donation_data = json_decode( $donation_data, true );
2498 }
2499 if ( ! is_array( $donation_data ) ) {
2500 return false;
2501 }
2502
2503 // Check if note exists.
2504 if ( empty( $donation_data['notes'] ) || ! isset( $donation_data['notes'][ $note_id ] ) ) {
2505 return false;
2506 }
2507
2508 // Remove the note.
2509 unset( $donation_data['notes'][ $note_id ] );
2510
2511 // Update donation_data in database.
2512 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2513
2514 return false !== $result;
2515 }
2516
2517 /**
2518 * Remove a refund from donation_data.
2519 *
2520 * Used when a refund is canceled.
2521 *
2522 * @param int $donation_id Donation ID.
2523 * @param string $refund_id Refund ID to remove.
2524 * @return array{removed: bool, refund_data: array<string, mixed>|null} Result with removed status and refund data.
2525 * @since 0.0.1
2526 */
2527 public static function remove_refund_from_donation_data( $donation_id, $refund_id ) {
2528 $result = [
2529 'removed' => false,
2530 'refund_data' => null,
2531 ];
2532
2533 if ( empty( $donation_id ) || empty( $refund_id ) ) {
2534 return $result;
2535 }
2536
2537 $donation = self::get( $donation_id );
2538 if ( ! $donation ) {
2539 return $result;
2540 }
2541
2542 // Get donation_data and parse if needed.
2543 $donation_data = $donation['donation_data'] ?? [];
2544 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2545 $donation_data = json_decode( $donation_data, true );
2546 }
2547 if ( ! is_array( $donation_data ) ) {
2548 return $result;
2549 }
2550
2551 // Check if refund exists.
2552 if ( empty( $donation_data['refunds'] ) || ! isset( $donation_data['refunds'][ $refund_id ] ) ) {
2553 return $result;
2554 }
2555
2556 // Store the refund data before removing.
2557 $result['refund_data'] = $donation_data['refunds'][ $refund_id ];
2558
2559 // Remove the refund.
2560 unset( $donation_data['refunds'][ $refund_id ] );
2561
2562 // Update donation_data in database.
2563 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2564
2565 $result['removed'] = false !== $update_result;
2566
2567 return $result;
2568 }
2569 }
2570