PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / database / tables / donations.php

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

1,840 lines 50.3 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 = 4;
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 'gateway' => [
122 'type' => 'string',
123 'default' => 'stripe',
124 ],
125 'payment_status' => [
126 'type' => 'string',
127 'default' => 'pending',
128 ],
129 'payment_mode' => [
130 'type' => 'string',
131 'default' => 'test',
132 ],
133 'donor_name' => [
134 'type' => 'string',
135 'default' => '',
136 ],
137 'donor_email' => [
138 'type' => 'string',
139 'default' => '',
140 ],
141 'donor_phone' => [
142 'type' => 'string',
143 'default' => '',
144 ],
145 'is_anonymous' => [
146 'type' => 'boolean',
147 'default' => false,
148 ],
149 'donation_type' => [
150 'type' => 'string',
151 'default' => 'one-time',
152 ],
153 'subscription_id' => [
154 'type' => 'string',
155 'default' => '',
156 ],
157 'subscription_status' => [
158 'type' => 'string',
159 'default' => '',
160 ],
161 'parent_subscription_id' => [
162 'type' => 'number',
163 'default' => 0,
164 ],
165 'donor_comment' => [
166 'type' => 'string',
167 'default' => '',
168 ],
169 'receipt_sent' => [
170 'type' => 'boolean',
171 'default' => false,
172 ],
173 'receipt_pdf_url' => [
174 'type' => 'string',
175 'default' => '',
176 ],
177 'donation_data' => [
178 'type' => 'array',
179 'default' => [],
180 ],
181 'log' => [
182 'type' => 'array',
183 'default' => [],
184 ],
185 'ip_address' => [
186 'type' => 'string',
187 'default' => '',
188 ],
189 'user_agent' => [
190 'type' => 'string',
191 'default' => '',
192 ],
193 'referer_url' => [
194 'type' => 'string',
195 'default' => '',
196 ],
197 'import_source_id' => [
198 'type' => 'number',
199 'default' => 0,
200 ],
201 'import_source' => [
202 'type' => 'string',
203 'default' => '',
204 ],
205 'created_at' => [
206 'type' => 'datetime',
207 ],
208 'updated_at' => [
209 'type' => 'datetime',
210 ],
211 ];
212 }
213
214 /**
215 * {@inheritDoc}
216 */
217 public function get_columns_definition() {
218 return [
219 'id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY',
220 'campaign_id BIGINT(20) UNSIGNED NOT NULL',
221 'donor_id BIGINT(20) UNSIGNED NULL',
222 'form_id BIGINT(20) UNSIGNED NULL',
223 'amount DECIMAL(26,8) NOT NULL',
224 'fees_covered DECIMAL(26,8) NOT NULL DEFAULT 0',
225 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0',
226 'currency VARCHAR(10) NOT NULL',
227 'transaction_id VARCHAR(255) NOT NULL',
228 'customer_id VARCHAR(50) NOT NULL',
229 'gateway VARCHAR(20) NOT NULL',
230 'payment_status VARCHAR(50) NOT NULL',
231 'payment_mode VARCHAR(20) NOT NULL',
232 'donor_name VARCHAR(255) NOT NULL',
233 'donor_email VARCHAR(255) NOT NULL',
234 'donor_phone VARCHAR(50) NOT NULL',
235 'is_anonymous TINYINT(1) NOT NULL DEFAULT 0',
236 'donation_type VARCHAR(30) NOT NULL',
237 'subscription_id VARCHAR(255) NOT NULL',
238 'subscription_status VARCHAR(30) NOT NULL',
239 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
240 'donor_comment TEXT',
241 'receipt_sent TINYINT(1) NOT NULL DEFAULT 0',
242 'receipt_pdf_url VARCHAR(255) NOT NULL',
243 'donation_data LONGTEXT',
244 'log LONGTEXT',
245 'ip_address VARCHAR(45) NOT NULL',
246 'user_agent TEXT',
247 'referer_url TEXT',
248 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
249 'import_source VARCHAR(20) NOT NULL DEFAULT ""',
250 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
251 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
252 'INDEX idx_campaign (campaign_id)',
253 'INDEX idx_donor (donor_id)',
254 'INDEX idx_status (payment_status)',
255 'INDEX idx_email (donor_email)',
256 'INDEX idx_created (created_at)',
257 'INDEX idx_form (form_id)',
258 'INDEX idx_subscription (subscription_id)',
259 'INDEX idx_subscription_status (subscription_status)',
260 'INDEX idx_parent_subscription (parent_subscription_id)',
261 'INDEX idx_import_source (import_source_id, import_source)',
262 ];
263 }
264
265 /**
266 * New columns added across versions.
267 *
268 * Version 2 added subscription support; version 4 added the
269 * source-agnostic pair `import_source_id` + `import_source` used by
270 * the migration tool for duplicate detection and rollback.
271 *
272 * {@inheritDoc}
273 *
274 * @since 1.0.0
275 */
276 public function get_new_columns_definition() {
277 return [
278 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type',
279 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id',
280 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status',
281 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url',
282 'import_source VARCHAR(20) NOT NULL DEFAULT "" AFTER import_source_id',
283 'INDEX idx_subscription (subscription_id)',
284 'INDEX idx_subscription_status (subscription_status)',
285 'INDEX idx_parent_subscription (parent_subscription_id)',
286 'INDEX idx_import_source (import_source_id, import_source)',
287 ];
288 }
289
290 /**
291 * Add a new donation record.
292 *
293 * @param array<mixed> $data Donation data to insert.
294 * @return int|false The donation ID on success, false on error.
295 * @since 0.0.1
296 */
297 public static function add( $data ) {
298 // Use isset check — empty() would reject campaign_id=0 which is valid for standalone forms.
299 if ( ! isset( $data['campaign_id'] ) ) {
300 return false;
301 }
302
303 $instance = self::get_instance();
304
305 // Set created_at if not provided (use GMT for consistency with TIMESTAMP column default).
306 if ( ! isset( $data['created_at'] ) ) {
307 $data['created_at'] = current_time( 'mysql', true );
308 }
309
310 $result = $instance->use_insert( $data );
311
312 if ( $result ) {
313 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $data['campaign_id'] ) ) );
314
315 // Notify integration hooks (e.g. OttoKit) about the new donation.
316 // Imported rows carry an import_source and are skipped: migrating
317 // historical donations must not replay automations.
318 if ( empty( $data['import_source'] ) ) {
319 $donation_id = absint( $result );
320 $donation = self::get( $donation_id );
321 $donation = is_array( $donation ) ? $donation : [];
322
323 /**
324 * Fires when a new donation record is created.
325 *
326 * @param int $donation_id Newly created donation ID.
327 * @param array<mixed> $donation Complete donation record.
328 * @since 1.1.0
329 */
330 do_action( 'suredonation_donation_created', $donation_id, $donation );
331 }
332 }
333
334 return $result;
335 }
336
337 /**
338 * Update a donation record.
339 *
340 * @param int $donation_id Donation ID to update.
341 * @param array<string,mixed> $data Data to update.
342 * @return int|false Number of rows updated or false on error.
343 * @since 0.0.1
344 */
345 public static function update( $donation_id, $data = [] ) {
346 if ( empty( $donation_id ) ) {
347 return false;
348 }
349
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'] ?? '' ) : '';
357 }
358
359 // Set updated_at.
360 $data['updated_at'] = current_time( 'mysql' );
361
362 $updated = self::get_instance()->use_update( $data, [ 'id' => absint( $donation_id ) ] );
363
364 // Status/amount changes (e.g. a webhook completing a pending donation)
365 // affect the cached stats and donor lists.
366 if ( $updated ) {
367 $donation = self::get( absint( $donation_id ) );
368 if ( ! empty( $donation['campaign_id'] ) ) {
369 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) );
370 }
371
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.
376 if ( isset( $data['payment_status'] ) ) {
377 $new_status = Helper::get_string_value( $data['payment_status'] );
378
379 if ( $new_status !== $old_status ) {
380 $donation = is_array( $donation ) ? $donation : [];
381
382 /**
383 * Fires when a donation's payment status changes.
384 *
385 * @param int $donation_id Donation ID.
386 * @param string $new_status New payment status.
387 * @param string $old_status Previous payment status (empty string if unknown).
388 * @param array<mixed> $donation Complete donation record after the update.
389 * @since 1.1.0
390 */
391 do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $donation );
392 }
393 }
394 }
395
396 return $updated;
397 }
398
399 /**
400 * Get a single donation by ID.
401 *
402 * @param int $donation_id Donation ID.
403 * @return array<mixed>|null Donation data or null if not found.
404 * @since 0.0.1
405 */
406 public static function get( $donation_id ) {
407 if ( empty( $donation_id ) ) {
408 return null;
409 }
410
411 $instance = self::get_instance();
412 global $wpdb;
413
414 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
415 $result = $wpdb->get_row(
416 $wpdb->prepare(
417 'SELECT * FROM %i WHERE id = %d',
418 $instance->get_tablename(),
419 absint( $donation_id )
420 ),
421 ARRAY_A
422 );
423
424 if ( ! $result ) {
425 return null;
426 }
427
428 return $instance->decode_by_datatype( $result );
429 }
430
431 /**
432 * Get all donations with pagination.
433 *
434 * @param int $limit Number of records to return.
435 * @param int $offset Offset for pagination.
436 * @param string $orderby Column to order by.
437 * @param string $order Order direction (ASC or DESC).
438 * @return array<mixed> Array of donations.
439 * @since 0.0.1
440 */
441 public static function get_all( $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
442 $instance = self::get_instance();
443 global $wpdb;
444 $table = $instance->get_tablename();
445
446 // Validate orderby column.
447 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
448 $orderby = 'created_at';
449 }
450
451 // Validate order direction.
452 $order = strtoupper( $order );
453 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
454 $order = 'DESC';
455 }
456
457 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
458 $results = 'ASC' === $order
459 ? $wpdb->get_results(
460 $wpdb->prepare(
461 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
462 $table,
463 $orderby,
464 absint( $offset ),
465 absint( $limit )
466 ),
467 ARRAY_A
468 )
469 : $wpdb->get_results(
470 $wpdb->prepare(
471 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
472 $table,
473 $orderby,
474 absint( $offset ),
475 absint( $limit )
476 ),
477 ARRAY_A
478 );
479 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
480
481 if ( ! $results || ! is_array( $results ) ) {
482 return [];
483 }
484
485 return array_map( [ $instance, 'decode_by_datatype' ], $results );
486 }
487
488 /**
489 * Get donations for admin listing with optional filters.
490 *
491 * @param string $status Payment status filter ('all' for no filter).
492 * @param int $campaign_id Campaign ID filter (0 for no filter).
493 * @param string $search Search term for donor_name, donor_email, or transaction_id.
494 * @param int $limit Number of records to return.
495 * @param int $offset Offset for pagination.
496 * @param string $orderby Column to order by.
497 * @param string $order Order direction (ASC or DESC).
498 * @return array<mixed> Array of donations.
499 * @since 0.0.1
500 */
501 public static function get_admin_list( $status = 'all', $campaign_id = 0, $search = '', $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
502 $instance = self::get_instance();
503 global $wpdb;
504 $table = $instance->get_tablename();
505
506 // Validate orderby column.
507 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
508 $orderby = 'created_at';
509 }
510
511 // Validate order direction.
512 $order = strtoupper( $order );
513 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
514 $order = 'DESC';
515 }
516
517 // Build query based on filters.
518 // Note: Renewal records (donation_type = 'renewal') are intentionally included in the listing.
519 // They are shown alongside parent subscriptions so admins can see all transaction activity.
520 // Renewals are also accessible from the parent donation's subscription detail billing history.
521 $has_status = 'all' !== $status;
522 $has_campaign = $campaign_id > 0;
523 $has_search = ! empty( $search );
524 $is_asc = 'ASC' === $order;
525
526 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
527
528 // All three filters.
529 if ( $has_status && $has_campaign && $has_search ) {
530 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
531 $results = $is_asc
532 ? $wpdb->get_results(
533 $wpdb->prepare(
534 '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',
535 $table,
536 sanitize_text_field( $status ),
537 absint( $campaign_id ),
538 $search_term,
539 $search_term,
540 $search_term,
541 $orderby,
542 absint( $offset ),
543 absint( $limit )
544 ),
545 ARRAY_A
546 )
547 : $wpdb->get_results(
548 $wpdb->prepare(
549 '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',
550 $table,
551 sanitize_text_field( $status ),
552 absint( $campaign_id ),
553 $search_term,
554 $search_term,
555 $search_term,
556 $orderby,
557 absint( $offset ),
558 absint( $limit )
559 ),
560 ARRAY_A
561 );
562 } elseif ( $has_status && $has_campaign ) {
563 $results = $is_asc
564 ? $wpdb->get_results(
565 $wpdb->prepare(
566 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
567 $table,
568 sanitize_text_field( $status ),
569 absint( $campaign_id ),
570 $orderby,
571 absint( $offset ),
572 absint( $limit )
573 ),
574 ARRAY_A
575 )
576 : $wpdb->get_results(
577 $wpdb->prepare(
578 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
579 $table,
580 sanitize_text_field( $status ),
581 absint( $campaign_id ),
582 $orderby,
583 absint( $offset ),
584 absint( $limit )
585 ),
586 ARRAY_A
587 );
588 } elseif ( $has_status && $has_search ) {
589 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
590 $results = $is_asc
591 ? $wpdb->get_results(
592 $wpdb->prepare(
593 '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',
594 $table,
595 sanitize_text_field( $status ),
596 $search_term,
597 $search_term,
598 $search_term,
599 $orderby,
600 absint( $offset ),
601 absint( $limit )
602 ),
603 ARRAY_A
604 )
605 : $wpdb->get_results(
606 $wpdb->prepare(
607 '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',
608 $table,
609 sanitize_text_field( $status ),
610 $search_term,
611 $search_term,
612 $search_term,
613 $orderby,
614 absint( $offset ),
615 absint( $limit )
616 ),
617 ARRAY_A
618 );
619 } elseif ( $has_campaign && $has_search ) {
620 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
621 $results = $is_asc
622 ? $wpdb->get_results(
623 $wpdb->prepare(
624 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
625 $table,
626 absint( $campaign_id ),
627 $search_term,
628 $search_term,
629 $search_term,
630 $orderby,
631 absint( $offset ),
632 absint( $limit )
633 ),
634 ARRAY_A
635 )
636 : $wpdb->get_results(
637 $wpdb->prepare(
638 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
639 $table,
640 absint( $campaign_id ),
641 $search_term,
642 $search_term,
643 $search_term,
644 $orderby,
645 absint( $offset ),
646 absint( $limit )
647 ),
648 ARRAY_A
649 );
650 } elseif ( $has_status ) {
651 $results = $is_asc
652 ? $wpdb->get_results(
653 $wpdb->prepare(
654 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
655 $table,
656 sanitize_text_field( $status ),
657 $orderby,
658 absint( $offset ),
659 absint( $limit )
660 ),
661 ARRAY_A
662 )
663 : $wpdb->get_results(
664 $wpdb->prepare(
665 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
666 $table,
667 sanitize_text_field( $status ),
668 $orderby,
669 absint( $offset ),
670 absint( $limit )
671 ),
672 ARRAY_A
673 );
674 } elseif ( $has_campaign ) {
675 $results = $is_asc
676 ? $wpdb->get_results(
677 $wpdb->prepare(
678 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
679 $table,
680 absint( $campaign_id ),
681 $orderby,
682 absint( $offset ),
683 absint( $limit )
684 ),
685 ARRAY_A
686 )
687 : $wpdb->get_results(
688 $wpdb->prepare(
689 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
690 $table,
691 absint( $campaign_id ),
692 $orderby,
693 absint( $offset ),
694 absint( $limit )
695 ),
696 ARRAY_A
697 );
698 } elseif ( $has_search ) {
699 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
700 $results = $is_asc
701 ? $wpdb->get_results(
702 $wpdb->prepare(
703 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
704 $table,
705 $search_term,
706 $search_term,
707 $search_term,
708 $orderby,
709 absint( $offset ),
710 absint( $limit )
711 ),
712 ARRAY_A
713 )
714 : $wpdb->get_results(
715 $wpdb->prepare(
716 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
717 $table,
718 $search_term,
719 $search_term,
720 $search_term,
721 $orderby,
722 absint( $offset ),
723 absint( $limit )
724 ),
725 ARRAY_A
726 );
727 } else {
728 $results = $is_asc
729 ? $wpdb->get_results(
730 $wpdb->prepare(
731 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
732 $table,
733 $orderby,
734 absint( $offset ),
735 absint( $limit )
736 ),
737 ARRAY_A
738 )
739 : $wpdb->get_results(
740 $wpdb->prepare(
741 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
742 $table,
743 $orderby,
744 absint( $offset ),
745 absint( $limit )
746 ),
747 ARRAY_A
748 );
749 }
750
751 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
752
753 if ( ! $results || ! is_array( $results ) ) {
754 return [];
755 }
756
757 return array_map( [ $instance, 'decode_by_datatype' ], $results );
758 }
759
760 /**
761 * Get donations by status with pagination.
762 *
763 * @param string $status Payment status.
764 * @param int $limit Number of records to return.
765 * @param int $offset Offset for pagination.
766 * @param string $orderby Column to order by.
767 * @param string $order Order direction (ASC or DESC).
768 * @return array<mixed> Array of donations.
769 * @since 0.0.1
770 */
771 public static function get_by_status( $status, $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
772 $instance = self::get_instance();
773 global $wpdb;
774 $table = $instance->get_tablename();
775
776 // Validate orderby column.
777 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
778 $orderby = 'created_at';
779 }
780
781 // Validate order direction.
782 $order = strtoupper( $order );
783 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
784 $order = 'DESC';
785 }
786
787 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
788 $results = 'ASC' === $order
789 ? $wpdb->get_results(
790 $wpdb->prepare(
791 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
792 $table,
793 sanitize_text_field( $status ),
794 $orderby,
795 absint( $offset ),
796 absint( $limit )
797 ),
798 ARRAY_A
799 )
800 : $wpdb->get_results(
801 $wpdb->prepare(
802 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
803 $table,
804 sanitize_text_field( $status ),
805 $orderby,
806 absint( $offset ),
807 absint( $limit )
808 ),
809 ARRAY_A
810 );
811 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
812
813 if ( ! $results || ! is_array( $results ) ) {
814 return [];
815 }
816
817 return array_map( [ $instance, 'decode_by_datatype' ], $results );
818 }
819
820 /**
821 * Get donations by campaign ID with pagination.
822 *
823 * @param int $campaign_id Campaign ID.
824 * @param int $limit Number of records to return.
825 * @param int $offset Offset for pagination.
826 * @param string $orderby Column to order by.
827 * @param string $order Order direction (ASC or DESC).
828 * @return array<mixed> Array of donations.
829 * @since 0.0.1
830 */
831 public static function get_by_campaign_id( $campaign_id, $limit = 100, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
832 if ( empty( $campaign_id ) ) {
833 return [];
834 }
835
836 $instance = self::get_instance();
837 global $wpdb;
838 $table = $instance->get_tablename();
839
840 // Validate orderby column.
841 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
842 $orderby = 'created_at';
843 }
844
845 // Validate order direction.
846 $order = strtoupper( $order );
847 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
848 $order = 'DESC';
849 }
850
851 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
852 $results = 'ASC' === $order
853 ? $wpdb->get_results(
854 $wpdb->prepare(
855 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
856 $table,
857 absint( $campaign_id ),
858 $orderby,
859 absint( $offset ),
860 absint( $limit )
861 ),
862 ARRAY_A
863 )
864 : $wpdb->get_results(
865 $wpdb->prepare(
866 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
867 $table,
868 absint( $campaign_id ),
869 $orderby,
870 absint( $offset ),
871 absint( $limit )
872 ),
873 ARRAY_A
874 );
875 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
876
877 if ( ! $results || ! is_array( $results ) ) {
878 return [];
879 }
880
881 return array_map( [ $instance, 'decode_by_datatype' ], $results );
882 }
883
884 /**
885 * Delete a donation record.
886 *
887 * @param int $donation_id Donation ID.
888 * @return int|false Number of rows deleted or false on error.
889 * @since 0.0.1
890 */
891 public static function delete( $donation_id ) {
892 if ( empty( $donation_id ) ) {
893 return false;
894 }
895
896 return self::get_instance()->use_delete( [ 'id' => absint( $donation_id ) ] );
897 }
898
899 /**
900 * Get donations by donor email.
901 *
902 * @param string $email Donor email.
903 * @return array<mixed> Array of donations.
904 * @since 0.0.1
905 */
906 public static function get_by_donor_email( $email ) {
907 if ( empty( $email ) ) {
908 return [];
909 }
910
911 $instance = self::get_instance();
912 global $wpdb;
913
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 );
923
924 if ( ! $results || ! is_array( $results ) ) {
925 return [];
926 }
927
928 return array_map( [ $instance, 'decode_by_datatype' ], $results );
929 }
930
931 /**
932 * Get donation by transaction ID.
933 *
934 * @param string $transaction_id Transaction ID.
935 * @return array<string, mixed>|null Donation data or null if not found.
936 * @since 0.0.1
937 */
938 public static function get_by_transaction_id( $transaction_id ) {
939 if ( empty( $transaction_id ) ) {
940 return null;
941 }
942
943 $instance = self::get_instance();
944 global $wpdb;
945
946 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
947 $result = $wpdb->get_row(
948 $wpdb->prepare(
949 'SELECT * FROM %i WHERE transaction_id = %s LIMIT 1',
950 $instance->get_tablename(),
951 sanitize_text_field( $transaction_id )
952 ),
953 ARRAY_A
954 );
955
956 if ( ! $result ) {
957 return null;
958 }
959
960 return $instance->decode_by_datatype( $result );
961 }
962
963 /**
964 * Get total donations count (no filters).
965 *
966 * @return int Total count.
967 * @since 0.0.1
968 */
969 public static function count_all() {
970 $instance = self::get_instance();
971 global $wpdb;
972
973 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
974 $count = $wpdb->get_var(
975 $wpdb->prepare(
976 'SELECT COUNT(*) FROM %i',
977 $instance->get_tablename()
978 )
979 );
980
981 return is_numeric( $count ) ? (int) $count : 0;
982 }
983
984 /**
985 * Get total donations count by payment status.
986 *
987 * @param string $status Payment status.
988 * @return int Total count.
989 * @since 0.0.1
990 */
991 public static function count_by_status( $status ) {
992 $instance = self::get_instance();
993 global $wpdb;
994
995 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
996 $count = $wpdb->get_var(
997 $wpdb->prepare(
998 'SELECT COUNT(*) FROM %i WHERE payment_status = %s',
999 $instance->get_tablename(),
1000 sanitize_text_field( $status )
1001 )
1002 );
1003
1004 return is_numeric( $count ) ? (int) $count : 0;
1005 }
1006
1007 /**
1008 * Get total donations count by campaign.
1009 *
1010 * @param int $campaign_id Campaign ID.
1011 * @return int Total count.
1012 * @since 0.0.1
1013 */
1014 public static function count_by_campaign( $campaign_id ) {
1015 $instance = self::get_instance();
1016 global $wpdb;
1017
1018 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1019 $count = $wpdb->get_var(
1020 $wpdb->prepare(
1021 'SELECT COUNT(*) FROM %i WHERE campaign_id = %d',
1022 $instance->get_tablename(),
1023 absint( $campaign_id )
1024 )
1025 );
1026
1027 return is_numeric( $count ) ? (int) $count : 0;
1028 }
1029
1030 /**
1031 * Get total donations count by status and campaign.
1032 *
1033 * @param string $status Payment status ('all' for no filter).
1034 * @param int $campaign_id Optional campaign ID (0 for no filter).
1035 * @return int Total count.
1036 * @since 0.0.1
1037 */
1038 public static function get_total_donations_by_status( $status = 'all', $campaign_id = 0 ) {
1039 $instance = self::get_instance();
1040 global $wpdb;
1041
1042 // Both filters.
1043 if ( 'all' !== $status && $campaign_id > 0 ) {
1044 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1045 $count = $wpdb->get_var(
1046 $wpdb->prepare(
1047 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND campaign_id = %d',
1048 $instance->get_tablename(),
1049 sanitize_text_field( $status ),
1050 absint( $campaign_id )
1051 )
1052 );
1053 return is_numeric( $count ) ? (int) $count : 0;
1054 }
1055
1056 // Status filter only.
1057 if ( 'all' !== $status ) {
1058 return self::count_by_status( $status );
1059 }
1060
1061 // Campaign filter only.
1062 if ( $campaign_id > 0 ) {
1063 return self::count_by_campaign( $campaign_id );
1064 }
1065
1066 // No filters.
1067 return self::count_all();
1068 }
1069
1070 /**
1071 * Get campaign statistics.
1072 *
1073 * @param int $campaign_id Campaign ID.
1074 * @return array<string,mixed> Campaign statistics.
1075 * @since 0.0.1
1076 */
1077 public static function get_campaign_stats( $campaign_id ) {
1078 $instance = self::get_instance();
1079 global $wpdb;
1080
1081 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1082 $stats = $wpdb->get_row(
1083 $wpdb->prepare(
1084 "SELECT
1085 COUNT(*) as donation_count,
1086 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1087 COUNT(DISTINCT donor_email) as unique_donors,
1088 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1089 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1090 FROM %i
1091 WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded')",
1092 $instance->get_tablename(),
1093 absint( $campaign_id )
1094 ),
1095 ARRAY_A
1096 );
1097
1098 return $stats ? $stats : [
1099 'donation_count' => 0,
1100 'total_raised' => 0,
1101 'unique_donors' => 0,
1102 'average_donation' => 0,
1103 'largest_donation' => 0,
1104 ];
1105 }
1106
1107 /**
1108 * Get global dashboard statistics.
1109 *
1110 * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics.
1111 * @since 0.0.1
1112 */
1113 public static function get_dashboard_stats() {
1114 $instance = self::get_instance();
1115 global $wpdb;
1116
1117 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1118 $stats = $wpdb->get_row(
1119 $wpdb->prepare(
1120 "SELECT
1121 COUNT(*) as total_donations,
1122 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1123 COUNT(DISTINCT donor_email) as unique_donors,
1124 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1125 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1126 FROM %i
1127 WHERE payment_status IN ('completed', 'partially_refunded')",
1128 $instance->get_tablename()
1129 ),
1130 ARRAY_A
1131 );
1132
1133 return $stats ? $stats : [
1134 'total_donations' => 0,
1135 'total_raised' => 0,
1136 'unique_donors' => 0,
1137 'average_donation' => 0,
1138 'largest_donation' => 0,
1139 ];
1140 }
1141
1142 /**
1143 * Get recent donations globally (all campaigns).
1144 *
1145 * @param int $limit Number of donations to retrieve.
1146 * @return array<int, array<string, mixed>> Array of recent donations.
1147 * @since 0.0.1
1148 */
1149 public static function get_recent_donations_global( $limit = 5 ) {
1150 $instance = self::get_instance();
1151 global $wpdb;
1152
1153 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1154 $results = $wpdb->get_results(
1155 $wpdb->prepare(
1156 "SELECT * FROM %i WHERE payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
1157 $instance->get_tablename(),
1158 absint( $limit )
1159 ),
1160 ARRAY_A
1161 );
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 top campaigns by donations.
1172 *
1173 * @param int $limit Number of campaigns to retrieve.
1174 * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats.
1175 * @since 0.0.1
1176 */
1177 public static function get_top_campaigns( $limit = 5 ) {
1178 $instance = self::get_instance();
1179 global $wpdb;
1180
1181 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1182 $results = $wpdb->get_results(
1183 $wpdb->prepare(
1184 "SELECT
1185 campaign_id,
1186 COUNT(*) as donation_count,
1187 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1188 COUNT(DISTINCT donor_email) as unique_donors
1189 FROM %i
1190 WHERE payment_status IN ('completed', 'partially_refunded')
1191 GROUP BY campaign_id
1192 ORDER BY total_raised DESC
1193 LIMIT %d",
1194 $instance->get_tablename(),
1195 absint( $limit )
1196 ),
1197 ARRAY_A
1198 );
1199
1200 return $results ? $results : [];
1201 }
1202
1203 /**
1204 * Get donation trends over time.
1205 *
1206 * @param string $after Start date (ISO format).
1207 * @param string $before End date (ISO format).
1208 * @param string $group Grouping: 'day', 'week', or 'month'.
1209 * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends.
1210 * @since 0.0.1
1211 */
1212 public static function get_donation_trends( $after = '', $before = '', $group = 'day' ) {
1213 $instance = self::get_instance();
1214 global $wpdb;
1215
1216 // Default to last 30 days if no dates provided.
1217 if ( empty( $after ) ) {
1218 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
1219 }
1220 if ( empty( $before ) ) {
1221 $before = gmdate( 'Y-m-d' );
1222 }
1223
1224 // Determine date format based on grouping.
1225 switch ( $group ) {
1226 case 'month':
1227 $date_format = '%Y-%m-01';
1228 break;
1229 case 'week':
1230 $date_format = '%x-%v'; // ISO year-week.
1231 break;
1232 case 'day':
1233 default:
1234 $date_format = '%Y-%m-%d';
1235 break;
1236 }
1237
1238 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1239 $results = $wpdb->get_results(
1240 $wpdb->prepare(
1241 "SELECT
1242 DATE_FORMAT(created_at, %s) as period,
1243 COUNT(*) as donation_count,
1244 COALESCE(SUM(amount - refunded_amount), 0) as total_amount
1245 FROM %i
1246 WHERE payment_status IN ('completed', 'partially_refunded')
1247 AND DATE(created_at) >= %s
1248 AND DATE(created_at) <= %s
1249 GROUP BY period
1250 ORDER BY period ASC",
1251 $date_format,
1252 $instance->get_tablename(),
1253 $after,
1254 $before
1255 ),
1256 ARRAY_A
1257 );
1258
1259 return $results ? $results : [];
1260 }
1261
1262 /**
1263 * Get recent donations for a campaign.
1264 *
1265 * @param int $campaign_id Campaign ID.
1266 * @param int $limit Number of donations to retrieve.
1267 * @return array<mixed> Array of recent donations.
1268 * @since 0.0.1
1269 */
1270 public static function get_recent_donations( $campaign_id, $limit = 5 ) {
1271 $instance = self::get_instance();
1272 global $wpdb;
1273
1274 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1275 $results = $wpdb->get_results(
1276 $wpdb->prepare(
1277 "SELECT * FROM %i WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
1278 $instance->get_tablename(),
1279 absint( $campaign_id ),
1280 absint( $limit )
1281 ),
1282 ARRAY_A
1283 );
1284
1285 if ( ! $results || ! is_array( $results ) ) {
1286 return [];
1287 }
1288
1289 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1290 }
1291
1292 /**
1293 * Get paginated donations for a specific donor.
1294 *
1295 * @param int $donor_id Donor ID.
1296 * @param int $limit Number of records to return.
1297 * @param int $offset Offset for pagination.
1298 * @return array{donations: array<int, array<string, mixed>>, total: int} Paginated donations and total count.
1299 * @since 1.0.0
1300 */
1301 public static function get_by_donor_id( $donor_id, $limit = 10, $offset = 0 ) {
1302 if ( empty( $donor_id ) ) {
1303 return [
1304 'donations' => [],
1305 'total' => 0,
1306 ];
1307 }
1308
1309 $instance = self::get_instance();
1310 global $wpdb;
1311 $table = $instance->get_tablename();
1312
1313 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1314
1315 $total = $wpdb->get_var(
1316 $wpdb->prepare(
1317 'SELECT COUNT(*) FROM %i WHERE donor_id = %d',
1318 $table,
1319 absint( $donor_id )
1320 )
1321 );
1322
1323 $results = $wpdb->get_results(
1324 $wpdb->prepare(
1325 'SELECT * FROM %i WHERE donor_id = %d ORDER BY created_at DESC LIMIT %d, %d',
1326 $table,
1327 absint( $donor_id ),
1328 absint( $offset ),
1329 absint( $limit )
1330 ),
1331 ARRAY_A
1332 );
1333
1334 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1335
1336 if ( ! $results || ! is_array( $results ) ) {
1337 $results = [];
1338 }
1339
1340 return [
1341 'donations' => array_map( [ $instance, 'decode_by_datatype' ], $results ),
1342 'total' => is_numeric( $total ) ? (int) $total : 0,
1343 ];
1344 }
1345
1346 /**
1347 * Get donation activity data for a specific donor (for chart).
1348 *
1349 * @param int $donor_id Donor ID.
1350 * @param string $after Start date (Y-m-d).
1351 * @param string $before End date (Y-m-d).
1352 * @return array{chart_data: array<int, array{date: string, amount: float}>, stats: array{lifetime: float, highest: float, average: float}} Activity data.
1353 * @since 1.0.0
1354 */
1355 public static function get_donor_activity( $donor_id, $after = '', $before = '' ) {
1356 if ( empty( $donor_id ) ) {
1357 return [
1358 'chart_data' => [],
1359 'stats' => [
1360 'lifetime' => 0,
1361 'highest' => 0,
1362 'average' => 0,
1363 ],
1364 ];
1365 }
1366
1367 $instance = self::get_instance();
1368 global $wpdb;
1369 $table = $instance->get_tablename();
1370
1371 // Default date range: last 30 days.
1372 if ( empty( $after ) ) {
1373 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
1374 }
1375 if ( empty( $before ) ) {
1376 $before = gmdate( 'Y-m-d' );
1377 }
1378
1379 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1380
1381 // Chart data: donations grouped by date.
1382 $chart_data = $wpdb->get_results(
1383 $wpdb->prepare(
1384 "SELECT DATE(created_at) as date, COALESCE(SUM(amount), 0) as amount
1385 FROM %i
1386 WHERE donor_id = %d
1387 AND payment_status IN ('completed', 'partially_refunded')
1388 AND DATE(created_at) >= %s
1389 AND DATE(created_at) <= %s
1390 GROUP BY DATE(created_at)
1391 ORDER BY date ASC",
1392 $table,
1393 absint( $donor_id ),
1394 $after,
1395 $before
1396 ),
1397 ARRAY_A
1398 );
1399
1400 // Lifetime stats for this donor.
1401 $stats = $wpdb->get_row(
1402 $wpdb->prepare(
1403 "SELECT
1404 COALESCE(SUM(amount - refunded_amount), 0) as lifetime,
1405 COALESCE(MAX(amount), 0) as highest,
1406 COALESCE(AVG(amount), 0) as average
1407 FROM %i
1408 WHERE donor_id = %d AND payment_status IN ('completed', 'partially_refunded')",
1409 $table,
1410 absint( $donor_id )
1411 ),
1412 ARRAY_A
1413 );
1414
1415 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1416
1417 $stats = is_array( $stats ) ? $stats : [];
1418
1419 return [
1420 'chart_data' => is_array( $chart_data ) ? $chart_data : [],
1421 'stats' => [
1422 'lifetime' => is_numeric( $stats['lifetime'] ?? 0 ) ? round( (float) ( $stats['lifetime'] ?? 0 ), 2 ) : 0,
1423 'highest' => is_numeric( $stats['highest'] ?? 0 ) ? round( (float) ( $stats['highest'] ?? 0 ), 2 ) : 0,
1424 'average' => is_numeric( $stats['average'] ?? 0 ) ? round( (float) ( $stats['average'] ?? 0 ), 2 ) : 0,
1425 ],
1426 ];
1427 }
1428
1429 /**
1430 * Update donation status.
1431 *
1432 * @param int $donation_id Donation ID.
1433 * @param string $status New status.
1434 * @return int|false Number of rows updated or false on error.
1435 * @since 0.0.1
1436 */
1437 public static function update_status( $donation_id, $status ) {
1438 if ( empty( $donation_id ) || ! in_array( $status, self::$valid_statuses, true ) ) {
1439 return false;
1440 }
1441
1442 return self::update( $donation_id, [ 'payment_status' => $status ] );
1443 }
1444
1445 /**
1446 * Get valid payment statuses.
1447 *
1448 * @return array<string> Valid statuses.
1449 * @since 0.0.1
1450 */
1451 public static function get_valid_statuses() {
1452 return self::$valid_statuses;
1453 }
1454
1455 /**
1456 * Add a log entry to a donation.
1457 *
1458 * @param int $donation_id Donation ID.
1459 * @param string $action Action type (e.g., 'status_change', 'refund', 'webhook').
1460 * @param string $message Log message.
1461 * @param array<string, mixed> $data Optional additional data.
1462 * @return int|false Number of rows updated or false on error.
1463 * @since 0.0.1
1464 */
1465 public static function add_log( $donation_id, $action, $message, $data = [] ) {
1466 if ( empty( $donation_id ) ) {
1467 return false;
1468 }
1469
1470 $donation = self::get( $donation_id );
1471 if ( ! $donation ) {
1472 return false;
1473 }
1474
1475 // Get existing log or initialize empty array.
1476 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
1477 $log_data = $donation['log'] ?? [];
1478 if ( is_array( $log_data ) ) {
1479 $log = $log_data;
1480 } elseif ( is_string( $log_data ) && ! empty( $log_data ) ) {
1481 $log = json_decode( $log_data, true );
1482 if ( ! is_array( $log ) ) {
1483 $log = [];
1484 }
1485 } else {
1486 $log = [];
1487 }
1488
1489 // Add new log entry.
1490 $log[] = [
1491 'action' => sanitize_text_field( $action ),
1492 'message' => sanitize_text_field( $message ),
1493 'data' => $data,
1494 'timestamp' => current_time( 'mysql' ),
1495 ];
1496
1497 return self::update( $donation_id, [ 'log' => $log ] );
1498 }
1499
1500 /**
1501 * Get log entries for a donation.
1502 *
1503 * @param int $donation_id Donation ID.
1504 * @return array<int, array<string, mixed>> Log entries.
1505 * @since 0.0.1
1506 */
1507 public static function get_log( $donation_id ) {
1508 if ( empty( $donation_id ) ) {
1509 return [];
1510 }
1511
1512 $donation = self::get( $donation_id );
1513 if ( ! $donation || empty( $donation['log'] ) ) {
1514 return [];
1515 }
1516
1517 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
1518 $log_data = $donation['log'];
1519 if ( is_array( $log_data ) ) {
1520 return $log_data;
1521 }
1522
1523 if ( is_string( $log_data ) ) {
1524 $log = json_decode( $log_data, true );
1525 return is_array( $log ) ? $log : [];
1526 }
1527
1528 return [];
1529 }
1530
1531 /**
1532 * Add refund data to donation_data for audit trail and duplicate prevention.
1533 *
1534 * Stores each refund with its ID as the key for O(1) lookups.
1535 *
1536 * @param int $donation_id Donation ID.
1537 * @param array<string, mixed> $refund_data Refund data to store.
1538 * @return bool True on success, false on failure.
1539 * @since 0.0.1
1540 */
1541 public static function add_refund_to_donation_data( $donation_id, $refund_data ) {
1542 $refund_id = $refund_data['refund_id'] ?? '';
1543
1544 if ( empty( $refund_id ) || empty( $donation_id ) ) {
1545 return false;
1546 }
1547
1548 $donation = self::get( $donation_id );
1549 if ( ! $donation ) {
1550 return false;
1551 }
1552
1553 // Get existing donation_data.
1554 $donation_data = $donation['donation_data'] ?? [];
1555 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1556 $donation_data = json_decode( $donation_data, true );
1557 }
1558 if ( ! is_array( $donation_data ) ) {
1559 $donation_data = [];
1560 }
1561
1562 // Initialize refunds array if not exists.
1563 if ( ! isset( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
1564 $donation_data['refunds'] = [];
1565 }
1566
1567 // Store with refund ID as key for O(1) lookup (duplicate prevention).
1568 $donation_data['refunds'][ $refund_id ] = $refund_data;
1569
1570 // Update donation_data in database.
1571 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1572
1573 return false !== $result;
1574 }
1575
1576 /**
1577 * Check if a refund already exists in the donation data.
1578 *
1579 * This prevents duplicate processing of the same refund.
1580 *
1581 * @param int $donation_id Donation ID.
1582 * @param string $refund_id Refund ID to check.
1583 * @return bool True if refund already exists, false otherwise.
1584 * @since 0.0.1
1585 */
1586 public static function check_refund_exists( $donation_id, $refund_id ) {
1587 if ( empty( $donation_id ) || empty( $refund_id ) ) {
1588 return false;
1589 }
1590
1591 $donation = self::get( $donation_id );
1592 if ( ! $donation ) {
1593 return false;
1594 }
1595
1596 // Get donation_data and parse if needed.
1597 $donation_data = $donation['donation_data'] ?? [];
1598 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1599 $donation_data = json_decode( $donation_data, true );
1600 }
1601 if ( ! is_array( $donation_data ) ) {
1602 return false;
1603 }
1604
1605 // Check if refunds array exists and contains this refund ID.
1606 if ( empty( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
1607 return false;
1608 }
1609
1610 // O(1) lookup using refund ID as array key.
1611 return isset( $donation_data['refunds'][ $refund_id ] );
1612 }
1613
1614 /**
1615 * Add a note to a donation.
1616 *
1617 * @param int $donation_id Donation ID.
1618 * @param string $note_content Note content.
1619 * @param int $author_id Author user ID.
1620 * @return array{success: bool, note_id: string|null} Result with success status and note ID.
1621 * @since 0.0.1
1622 */
1623 public static function add_note( $donation_id, $note_content, $author_id = 0 ) {
1624 $result = [
1625 'success' => false,
1626 'note_id' => null,
1627 ];
1628
1629 if ( empty( $donation_id ) || empty( $note_content ) ) {
1630 return $result;
1631 }
1632
1633 $donation = self::get( $donation_id );
1634 if ( ! $donation ) {
1635 return $result;
1636 }
1637
1638 // Get existing donation_data.
1639 $donation_data = $donation['donation_data'] ?? [];
1640 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1641 $donation_data = json_decode( $donation_data, true );
1642 }
1643 if ( ! is_array( $donation_data ) ) {
1644 $donation_data = [];
1645 }
1646
1647 // Initialize notes array if not exists.
1648 if ( ! isset( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
1649 $donation_data['notes'] = [];
1650 }
1651
1652 // Generate unique note ID.
1653 $note_id = uniqid( 'note_', true );
1654
1655 // Get author info.
1656 $author_name = __( 'System', 'suredonation' );
1657 if ( $author_id > 0 ) {
1658 $user = get_userdata( $author_id );
1659 if ( $user ) {
1660 $author_name = $user->display_name;
1661 }
1662 }
1663
1664 // Add new note.
1665 $donation_data['notes'][ $note_id ] = [
1666 'id' => $note_id,
1667 'content' => wp_kses_post( $note_content ),
1668 'author_id' => $author_id,
1669 'author_name' => $author_name,
1670 'created_at' => current_time( 'mysql' ),
1671 ];
1672
1673 // Update donation_data in database.
1674 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1675
1676 if ( false !== $update_result ) {
1677 $result['success'] = true;
1678 $result['note_id'] = $note_id;
1679 }
1680
1681 return $result;
1682 }
1683
1684 /**
1685 * Get notes for a donation with pagination.
1686 *
1687 * @param int $donation_id Donation ID.
1688 * @param int $page Current page (1-indexed).
1689 * @param int $per_page Notes per page.
1690 * @return array{notes: array<int, array<string, mixed>>, total: int, total_pages: int} Paginated notes.
1691 * @since 0.0.1
1692 */
1693 public static function get_notes( $donation_id, $page = 1, $per_page = 3 ) {
1694 $result = [
1695 'notes' => [],
1696 'total' => 0,
1697 'total_pages' => 0,
1698 ];
1699
1700 if ( empty( $donation_id ) ) {
1701 return $result;
1702 }
1703
1704 $donation = self::get( $donation_id );
1705 if ( ! $donation ) {
1706 return $result;
1707 }
1708
1709 // Get donation_data and parse if needed.
1710 $donation_data = $donation['donation_data'] ?? [];
1711 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1712 $donation_data = json_decode( $donation_data, true );
1713 }
1714 if ( ! is_array( $donation_data ) ) {
1715 return $result;
1716 }
1717
1718 // Get notes array.
1719 if ( empty( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
1720 return $result;
1721 }
1722
1723 // Convert to array values and sort by created_at (newest first).
1724 $all_notes = array_values( $donation_data['notes'] );
1725 usort(
1726 $all_notes,
1727 static function ( $a, $b ) {
1728 return strtotime( $b['created_at'] ?? '0' ) - strtotime( $a['created_at'] ?? '0' );
1729 }
1730 );
1731
1732 $total = count( $all_notes );
1733 $total_pages = (int) ceil( $total / $per_page );
1734 $offset = ( $page - 1 ) * $per_page;
1735
1736 // Get paginated notes.
1737 $notes = array_slice( $all_notes, $offset, $per_page );
1738
1739 return [
1740 'notes' => $notes,
1741 'total' => $total,
1742 'total_pages' => $total_pages,
1743 ];
1744 }
1745
1746 /**
1747 * Delete a note from a donation.
1748 *
1749 * @param int $donation_id Donation ID.
1750 * @param string $note_id Note ID to delete.
1751 * @return bool True on success, false on failure.
1752 * @since 0.0.1
1753 */
1754 public static function delete_note( $donation_id, $note_id ) {
1755 if ( empty( $donation_id ) || empty( $note_id ) ) {
1756 return false;
1757 }
1758
1759 $donation = self::get( $donation_id );
1760 if ( ! $donation ) {
1761 return false;
1762 }
1763
1764 // Get donation_data and parse if needed.
1765 $donation_data = $donation['donation_data'] ?? [];
1766 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1767 $donation_data = json_decode( $donation_data, true );
1768 }
1769 if ( ! is_array( $donation_data ) ) {
1770 return false;
1771 }
1772
1773 // Check if note exists.
1774 if ( empty( $donation_data['notes'] ) || ! isset( $donation_data['notes'][ $note_id ] ) ) {
1775 return false;
1776 }
1777
1778 // Remove the note.
1779 unset( $donation_data['notes'][ $note_id ] );
1780
1781 // Update donation_data in database.
1782 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1783
1784 return false !== $result;
1785 }
1786
1787 /**
1788 * Remove a refund from donation_data.
1789 *
1790 * Used when a refund is canceled.
1791 *
1792 * @param int $donation_id Donation ID.
1793 * @param string $refund_id Refund ID to remove.
1794 * @return array{removed: bool, refund_data: array<string, mixed>|null} Result with removed status and refund data.
1795 * @since 0.0.1
1796 */
1797 public static function remove_refund_from_donation_data( $donation_id, $refund_id ) {
1798 $result = [
1799 'removed' => false,
1800 'refund_data' => null,
1801 ];
1802
1803 if ( empty( $donation_id ) || empty( $refund_id ) ) {
1804 return $result;
1805 }
1806
1807 $donation = self::get( $donation_id );
1808 if ( ! $donation ) {
1809 return $result;
1810 }
1811
1812 // Get donation_data and parse if needed.
1813 $donation_data = $donation['donation_data'] ?? [];
1814 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1815 $donation_data = json_decode( $donation_data, true );
1816 }
1817 if ( ! is_array( $donation_data ) ) {
1818 return $result;
1819 }
1820
1821 // Check if refund exists.
1822 if ( empty( $donation_data['refunds'] ) || ! isset( $donation_data['refunds'][ $refund_id ] ) ) {
1823 return $result;
1824 }
1825
1826 // Store the refund data before removing.
1827 $result['refund_data'] = $donation_data['refunds'][ $refund_id ];
1828
1829 // Remove the refund.
1830 unset( $donation_data['refunds'][ $refund_id ] );
1831
1832 // Update donation_data in database.
1833 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1834
1835 $result['removed'] = false !== $update_result;
1836
1837 return $result;
1838 }
1839 }
1840