PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.0.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.0.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.0.0, at inc/database/tables/donations.php

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