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

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

2,256 lines 67.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureDonation Database Donations Table Class.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Database\Tables;
9
10 use SureDonation\Inc\Campaigns\Campaign_Stats;
11 use SureDonation\Inc\Database\Base;
12 use SureDonation\Inc\Helper;
13 use SureDonation\Inc\Traits\Get_Instance;
14
15 // Exit if accessed directly.
16 defined( 'ABSPATH' ) || exit;
17
18 /**
19 * SureDonation Database Donations Table Class.
20 *
21 * @since 0.0.1
22 */
23 class Donations extends Base {
24 use Get_Instance;
25
26 /**
27 * Table suffix.
28 *
29 * @var string
30 * @since 0.0.1
31 */
32 protected $table_suffix = 'donations';
33
34 /**
35 * Table version.
36 *
37 * @var int
38 * @since 0.0.1
39 */
40 protected $table_version = 5;
41
42 /**
43 * Valid payment statuses.
44 *
45 * @var array<string>
46 * @since 0.0.1
47 */
48 private static $valid_statuses = [
49 'pending',
50 'processing',
51 'completed',
52 'failed',
53 'refunded',
54 'partially_refunded',
55 'cancelled',
56 'suspicious',
57 ];
58
59 /**
60 * Valid order columns.
61 *
62 * @var array<string>
63 * @since 0.0.1
64 */
65 private static $valid_order_columns = [
66 'id',
67 'campaign_id',
68 'amount',
69 'created_at',
70 'updated_at',
71 'payment_status',
72 'donor_name',
73 'donor_email',
74 'subscription_status',
75 'subscription_id',
76 ];
77
78 /**
79 * {@inheritDoc}
80 */
81 public function get_schema() {
82 return [
83 'id' => [
84 'type' => 'number',
85 ],
86 'campaign_id' => [
87 'type' => 'number',
88 ],
89 'donor_id' => [
90 'type' => 'number',
91 'default' => 0,
92 ],
93 'form_id' => [
94 'type' => 'number',
95 'default' => 0,
96 ],
97 'amount' => [
98 'type' => 'string',
99 'default' => '0.00000000',
100 ],
101 'fees_covered' => [
102 'type' => 'string',
103 'default' => '0.00000000',
104 ],
105 'refunded_amount' => [
106 'type' => 'string',
107 'default' => '0.00000000',
108 ],
109 'currency' => [
110 'type' => 'string',
111 'default' => 'USD',
112 ],
113 'transaction_id' => [
114 'type' => 'string',
115 'default' => '',
116 ],
117 'customer_id' => [
118 'type' => 'string',
119 'default' => '',
120 ],
121 'stripe_account_id' => [
122 'type' => 'string',
123 'default' => '',
124 ],
125 'gateway' => [
126 'type' => 'string',
127 'default' => 'stripe',
128 ],
129 'payment_status' => [
130 'type' => 'string',
131 'default' => 'pending',
132 ],
133 'payment_mode' => [
134 'type' => 'string',
135 'default' => 'test',
136 ],
137 'donor_name' => [
138 'type' => 'string',
139 'default' => '',
140 ],
141 'donor_email' => [
142 'type' => 'string',
143 'default' => '',
144 ],
145 'donor_phone' => [
146 'type' => 'string',
147 'default' => '',
148 ],
149 'is_anonymous' => [
150 'type' => 'boolean',
151 'default' => false,
152 ],
153 'donation_type' => [
154 'type' => 'string',
155 'default' => 'one-time',
156 ],
157 'subscription_id' => [
158 'type' => 'string',
159 'default' => '',
160 ],
161 'subscription_status' => [
162 'type' => 'string',
163 'default' => '',
164 ],
165 'parent_subscription_id' => [
166 'type' => 'number',
167 'default' => 0,
168 ],
169 'donor_comment' => [
170 'type' => 'string',
171 'default' => '',
172 ],
173 'receipt_sent' => [
174 'type' => 'boolean',
175 'default' => false,
176 ],
177 'receipt_pdf_url' => [
178 'type' => 'string',
179 'default' => '',
180 ],
181 'donation_data' => [
182 'type' => 'array',
183 'default' => [],
184 ],
185 'log' => [
186 'type' => 'array',
187 'default' => [],
188 ],
189 'ip_address' => [
190 'type' => 'string',
191 'default' => '',
192 ],
193 'user_agent' => [
194 'type' => 'string',
195 'default' => '',
196 ],
197 'referer_url' => [
198 'type' => 'string',
199 'default' => '',
200 ],
201 'import_source_id' => [
202 'type' => 'number',
203 'default' => 0,
204 ],
205 'import_source' => [
206 'type' => 'string',
207 'default' => '',
208 ],
209 'created_at' => [
210 'type' => 'datetime',
211 ],
212 'updated_at' => [
213 'type' => 'datetime',
214 ],
215 ];
216 }
217
218 /**
219 * {@inheritDoc}
220 */
221 public function get_columns_definition() {
222 return [
223 'id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY',
224 'campaign_id BIGINT(20) UNSIGNED NOT NULL',
225 'donor_id BIGINT(20) UNSIGNED NULL',
226 'form_id BIGINT(20) UNSIGNED NULL',
227 'amount DECIMAL(26,8) NOT NULL',
228 'fees_covered DECIMAL(26,8) NOT NULL DEFAULT 0',
229 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0',
230 'currency VARCHAR(10) NOT NULL',
231 'transaction_id VARCHAR(255) NOT NULL',
232 'customer_id VARCHAR(50) NOT NULL',
233 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\'',
234 'gateway VARCHAR(20) NOT NULL',
235 'payment_status VARCHAR(50) NOT NULL',
236 'payment_mode VARCHAR(20) NOT NULL',
237 'donor_name VARCHAR(255) NOT NULL',
238 'donor_email VARCHAR(255) NOT NULL',
239 'donor_phone VARCHAR(50) NOT NULL',
240 'is_anonymous TINYINT(1) NOT NULL DEFAULT 0',
241 'donation_type VARCHAR(30) NOT NULL',
242 'subscription_id VARCHAR(255) NOT NULL',
243 'subscription_status VARCHAR(30) NOT NULL',
244 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
245 'donor_comment TEXT',
246 'receipt_sent TINYINT(1) NOT NULL DEFAULT 0',
247 'receipt_pdf_url VARCHAR(255) NOT NULL',
248 'donation_data LONGTEXT',
249 'log LONGTEXT',
250 'ip_address VARCHAR(45) NOT NULL',
251 'user_agent TEXT',
252 'referer_url TEXT',
253 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
254 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'',
255 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
256 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
257 'INDEX idx_campaign (campaign_id)',
258 'INDEX idx_donor (donor_id)',
259 'INDEX idx_status (payment_status)',
260 'INDEX idx_email (donor_email)',
261 'INDEX idx_created (created_at)',
262 'INDEX idx_form (form_id)',
263 'INDEX idx_subscription (subscription_id)',
264 'INDEX idx_subscription_status (subscription_status)',
265 'INDEX idx_parent_subscription (parent_subscription_id)',
266 'INDEX idx_import_source (import_source_id, import_source)',
267 'INDEX idx_stripe_account (stripe_account_id)',
268 ];
269 }
270
271 /**
272 * New columns added across versions.
273 *
274 * Version 2 added subscription support; version 4 added the
275 * source-agnostic pair `import_source_id` + `import_source` used by
276 * the migration tool for duplicate detection and rollback; version 5
277 * added `stripe_account_id` so donations record which connected Stripe
278 * account processed them (multiple Stripe accounts support).
279 *
280 * {@inheritDoc}
281 *
282 * @since 1.0.0
283 */
284 public function get_new_columns_definition() {
285 return [
286 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type',
287 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id',
288 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status',
289 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url',
290 'import_source VARCHAR(20) NOT NULL DEFAULT \'\' AFTER import_source_id',
291 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\' AFTER customer_id',
292 'INDEX idx_subscription (subscription_id)',
293 'INDEX idx_subscription_status (subscription_status)',
294 'INDEX idx_parent_subscription (parent_subscription_id)',
295 'INDEX idx_import_source (import_source_id, import_source)',
296 'INDEX idx_stripe_account (stripe_account_id)',
297 ];
298 }
299
300 /**
301 * One-time data migrations for the donations table.
302 *
303 * Version 5 introduced the `stripe_account_id` column. Before multi-account there
304 * could only be a single connected Stripe account, so every pre-v5 Stripe
305 * donation belongs to the current (single) default account. Backfill it so
306 * refunds and subscription lifecycle actions keep routing to the originating
307 * account after a second account is connected and the default is switched.
308 * Idempotent (touches only empty rows) and gated to the upgrade into v5.
309 *
310 * @return void
311 * @since 1.3.0
312 */
313 public function run_data_migrations() {
314 // A failed CREATE/ALTER earlier in this upgrade already cleared the flag;
315 // the column may not exist, so don't run an UPDATE against it.
316 if ( ! $this->db_upgradable ) {
317 return;
318 }
319
320 // Already on v5+ (e.g. a later upgrade) — the backfill is done.
321 if ( $this->prev_version >= 5 ) {
322 return;
323 }
324
325 if ( ! class_exists( '\SureDonation\Inc\Payments\Stripe\Stripe_Helper' ) ) {
326 return;
327 }
328
329 // Runs during the v5 DB upgrade — before any second account can be
330 // connected via the UI — so the default is still the single legacy account.
331 $account_id = \SureDonation\Inc\Payments\Stripe\Stripe_Helper::get_default_account_id();
332 if ( ! is_string( $account_id ) || '' === $account_id ) {
333 return;
334 }
335
336 global $wpdb;
337 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill of a newly added column; not cacheable.
338 $result = $wpdb->query(
339 $wpdb->prepare(
340 'UPDATE %i SET stripe_account_id = %s WHERE gateway = %s AND ( stripe_account_id = %s OR stripe_account_id IS NULL )',
341 $this->get_tablename(),
342 $account_id,
343 'stripe',
344 ''
345 )
346 );
347
348 // A transient failure (e.g. lock wait timeout on a busy table) must not
349 // persist the new version: `prev_version >= 5` would then skip this
350 // one-shot backfill forever. Leaving the version unwritten makes the
351 // idempotent sequence retry on the next request.
352 if ( false === $result ) {
353 $this->db_upgradable = false;
354 }
355 }
356
357 /**
358 * Add a new donation record.
359 *
360 * @param array<mixed> $data Donation data to insert.
361 * @return int|false The donation ID on success, false on error.
362 * @since 0.0.1
363 */
364 public static function add( $data ) {
365 // Use isset check — empty() would reject campaign_id=0 which is valid for standalone forms.
366 if ( ! isset( $data['campaign_id'] ) ) {
367 return false;
368 }
369
370 $instance = self::get_instance();
371
372 // Set created_at if not provided (use GMT for consistency with TIMESTAMP column default).
373 if ( ! isset( $data['created_at'] ) ) {
374 $data['created_at'] = current_time( 'mysql', true );
375 }
376
377 $result = $instance->use_insert( $data );
378
379 if ( $result ) {
380 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $data['campaign_id'] ) ) );
381
382 // Notify integration hooks (e.g. OttoKit) about the new donation.
383 // Imported rows carry an import_source and are skipped: migrating
384 // historical donations must not replay automations.
385 if ( empty( $data['import_source'] ) ) {
386 $donation_id = absint( $result );
387 $donation = self::get( $donation_id );
388 $donation = is_array( $donation ) ? $donation : [];
389
390 // Curated, integration-safe payload (no PII/internal columns)
391 // shared by every hook below. See self::get_integration_payload().
392 $payload = self::get_integration_payload( $donation );
393
394 /**
395 * Fires when a new donation record is created.
396 *
397 * @param int $donation_id Newly created donation ID.
398 * @param array<mixed> $donation Curated donation payload.
399 * @since 1.1.0
400 */
401 do_action( 'suredonation_donation_created', $donation_id, $payload );
402
403 /**
404 * Fires when a new donation record is created.
405 *
406 * Mirrors `suredonation_donation_created`; the OttoKit (formerly
407 * SureTriggers) "New Donation" trigger listens on this hook name.
408 *
409 * @param int $donation_id Newly created donation ID.
410 * @param array<mixed> $donation Curated donation payload.
411 * @since 1.2.0
412 */
413 do_action( 'suredonation_new_donation', $donation_id, $payload );
414
415 // Some donations are created already-completed rather than
416 // transitioning through update() — recurring renewals and
417 // admin-recorded paid donations. Fire the completion event here
418 // too so integration hooks still see them.
419 if ( 'completed' === ( $data['payment_status'] ?? '' ) ) {
420 /**
421 * Fires when a donation payment is completed.
422 *
423 * @param int $donation_id Donation ID.
424 * @param array<mixed> $donation Curated donation payload after insertion.
425 * @since 1.2.0
426 */
427 do_action( 'suredonation_donation_completed', $donation_id, $payload );
428 }
429 }
430 }
431
432 return $result;
433 }
434
435 /**
436 * Update a donation record.
437 *
438 * @param int $donation_id Donation ID to update.
439 * @param array<string,mixed> $data Data to update.
440 * @return int|false Number of rows updated or false on error.
441 * @since 0.0.1
442 */
443 public static function update( $donation_id, $data = [] ) {
444 if ( empty( $donation_id ) ) {
445 return false;
446 }
447
448 // Capture the current status and refunded amount before the write so
449 // integration hooks (e.g. OttoKit) can react to the transition and to
450 // refund events, not just the resulting values.
451 $old_status = '';
452 $old_refunded = 0.0;
453 if ( isset( $data['payment_status'] ) || isset( $data['refunded_amount'] ) ) {
454 $existing = self::get( absint( $donation_id ) );
455 $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : '';
456 $old_refunded = is_array( $existing ) ? Helper::get_float_value( $existing['refunded_amount'] ?? 0 ) : 0.0;
457 }
458
459 // Set updated_at.
460 $data['updated_at'] = current_time( 'mysql' );
461
462 $updated = self::get_instance()->use_update( $data, [ 'id' => absint( $donation_id ) ] );
463
464 // Status/amount changes (e.g. a webhook completing a pending donation)
465 // affect the cached stats and donor lists.
466 if ( $updated ) {
467 $donation = self::get( absint( $donation_id ) );
468 $donation = is_array( $donation ) ? $donation : [];
469 if ( ! empty( $donation['campaign_id'] ) ) {
470 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) );
471 }
472
473 // Curated, integration-safe payload (no PII/internal columns) shared
474 // by every hook below. See self::get_integration_payload().
475 $payload = self::get_integration_payload( $donation );
476
477 if ( isset( $data['payment_status'] ) ) {
478 $new_status = Helper::get_string_value( $data['payment_status'] );
479
480 if ( $new_status !== $old_status ) {
481 /**
482 * Fires when a donation's payment status changes.
483 *
484 * @param int $donation_id Donation ID.
485 * @param string $new_status New payment status.
486 * @param string $old_status Previous payment status (empty string if unknown).
487 * @param array<mixed> $donation Curated donation payload after the update.
488 * @since 1.1.0
489 */
490 do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $payload );
491
492 // Fire the completion event for any genuine transition into
493 // 'completed' — including admin review states (suspicious,
494 // cancelled) — but never for refund reversals that restore
495 // the 'completed' status (refunded/partially_refunded ->
496 // completed), which would replay the completion automation.
497 if ( 'completed' === $new_status && ! in_array( $old_status, [ 'completed', 'refunded', 'partially_refunded' ], true ) ) {
498 /**
499 * Fires when a donation payment is completed.
500 *
501 * @param int $donation_id Donation ID.
502 * @param array<mixed> $donation Curated donation payload after the update.
503 * @since 1.2.0
504 */
505 do_action( 'suredonation_donation_completed', absint( $donation_id ), $payload );
506 }
507 }
508 }
509
510 // A rise in refunded_amount means a refund was processed. Keying off
511 // the amount (not the status string) catches repeat partial refunds
512 // that leave the status as partially_refunded, and excludes refund
513 // reversals where the amount drops.
514 if ( isset( $data['refunded_amount'] ) ) {
515 $new_refunded = Helper::get_float_value( $data['refunded_amount'] );
516
517 if ( $new_refunded - $old_refunded > 0.0001 ) {
518 /**
519 * Fires when a donation is refunded, fully or partially.
520 *
521 * @param int $donation_id Donation ID.
522 * @param float $refund_amount Amount refunded in this event.
523 * @param float $total_refunded Cumulative amount refunded to date.
524 * @param array<mixed> $donation Curated donation payload after the update.
525 * @since 1.2.0
526 */
527 do_action( 'suredonation_donation_refunded', absint( $donation_id ), $new_refunded - $old_refunded, $new_refunded, $payload );
528 }
529 }
530 }
531
532 return $updated;
533 }
534
535 /**
536 * Build a curated donation payload for integration hooks.
537 *
538 * Trims the raw database row to the fields advertised in the OttoKit embed
539 * `sample_response`, omitting internal and PII columns that must not leave
540 * the site (ip_address, user_agent, referer_url, the admin `log`, the
541 * gateway `customer_id`, and the full `donation_data` submission). Donor
542 * identity is blanked for anonymous donations, and monetary values are cast
543 * to float to match the sample the automation builder maps against (the raw
544 * column is a DECIMAL string). Shared by every `do_action` in add()/update()
545 * so no listener — OttoKit or otherwise — receives the raw row.
546 *
547 * @param array<string,mixed> $donation Raw donation record from self::get().
548 * @return array<string,mixed> Curated, integration-safe payload.
549 * @since 1.2.0
550 */
551 public static function get_integration_payload( $donation ) {
552 if ( ! is_array( $donation ) ) {
553 return [];
554 }
555
556 $is_anonymous = ! empty( $donation['is_anonymous'] );
557
558 return [
559 'id' => isset( $donation['id'] ) ? absint( Helper::get_string_value( $donation['id'] ) ) : 0,
560 'campaign_id' => isset( $donation['campaign_id'] ) ? absint( Helper::get_string_value( $donation['campaign_id'] ) ) : 0,
561 'form_id' => isset( $donation['form_id'] ) ? absint( Helper::get_string_value( $donation['form_id'] ) ) : 0,
562 'donor_id' => isset( $donation['donor_id'] ) ? absint( Helper::get_string_value( $donation['donor_id'] ) ) : 0,
563 'donor_name' => $is_anonymous ? '' : Helper::get_string_value( $donation['donor_name'] ?? '' ),
564 'donor_email' => $is_anonymous ? '' : Helper::get_string_value( $donation['donor_email'] ?? '' ),
565 'donor_phone' => $is_anonymous ? '' : Helper::get_string_value( $donation['donor_phone'] ?? '' ),
566 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
567 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
568 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
569 'currency' => Helper::get_string_value( $donation['currency'] ?? '' ),
570 'gateway' => Helper::get_string_value( $donation['gateway'] ?? '' ),
571 'payment_status' => Helper::get_string_value( $donation['payment_status'] ?? '' ),
572 'payment_mode' => Helper::get_string_value( $donation['payment_mode'] ?? '' ),
573 'donation_type' => Helper::get_string_value( $donation['donation_type'] ?? '' ),
574 'transaction_id' => Helper::get_string_value( $donation['transaction_id'] ?? '' ),
575 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ),
576 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ),
577 'donor_comment' => $is_anonymous ? '' : Helper::get_string_value( $donation['donor_comment'] ?? '' ),
578 'is_anonymous' => $is_anonymous,
579 'created_at' => Helper::get_string_value( $donation['created_at'] ?? '' ),
580 'updated_at' => Helper::get_string_value( $donation['updated_at'] ?? '' ),
581 ];
582 }
583
584 /**
585 * Get a single donation by ID.
586 *
587 * @param int $donation_id Donation ID.
588 * @return array<mixed>|null Donation data or null if not found.
589 * @since 0.0.1
590 */
591 public static function get( $donation_id ) {
592 if ( empty( $donation_id ) ) {
593 return null;
594 }
595
596 $instance = self::get_instance();
597 global $wpdb;
598
599 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
600 $result = $wpdb->get_row(
601 $wpdb->prepare(
602 'SELECT * FROM %i WHERE id = %d',
603 $instance->get_tablename(),
604 absint( $donation_id )
605 ),
606 ARRAY_A
607 );
608
609 if ( ! $result ) {
610 return null;
611 }
612
613 return $instance->decode_by_datatype( $result );
614 }
615
616 /**
617 * Get all donations with pagination.
618 *
619 * @param int $limit Number of records to return.
620 * @param int $offset Offset for pagination.
621 * @param string $orderby Column to order by.
622 * @param string $order Order direction (ASC or DESC).
623 * @return array<mixed> Array of donations.
624 * @since 0.0.1
625 */
626 public static function get_all( $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
627 $instance = self::get_instance();
628 global $wpdb;
629 $table = $instance->get_tablename();
630
631 // Validate orderby column.
632 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
633 $orderby = 'created_at';
634 }
635
636 // Validate order direction.
637 $order = strtoupper( $order );
638 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
639 $order = 'DESC';
640 }
641
642 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
643 $results = 'ASC' === $order
644 ? $wpdb->get_results(
645 $wpdb->prepare(
646 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
647 $table,
648 $orderby,
649 absint( $offset ),
650 absint( $limit )
651 ),
652 ARRAY_A
653 )
654 : $wpdb->get_results(
655 $wpdb->prepare(
656 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
657 $table,
658 $orderby,
659 absint( $offset ),
660 absint( $limit )
661 ),
662 ARRAY_A
663 );
664 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
665
666 if ( ! $results || ! is_array( $results ) ) {
667 return [];
668 }
669
670 return array_map( [ $instance, 'decode_by_datatype' ], $results );
671 }
672
673 /**
674 * Get donations for admin listing with optional filters.
675 *
676 * @param string $status Payment status filter ('all' for no filter).
677 * @param int $campaign_id Campaign ID filter (0 for no filter).
678 * @param string $search Search term for donor_name, donor_email, or transaction_id.
679 * @param int $limit Number of records to return.
680 * @param int $offset Offset for pagination.
681 * @param string $orderby Column to order by.
682 * @param string $order Order direction (ASC or DESC).
683 * @return array<mixed> Array of donations.
684 * @since 0.0.1
685 */
686 public static function get_admin_list( $status = 'all', $campaign_id = 0, $search = '', $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
687 $instance = self::get_instance();
688 global $wpdb;
689 $table = $instance->get_tablename();
690
691 // Validate orderby column.
692 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
693 $orderby = 'created_at';
694 }
695
696 // Validate order direction.
697 $order = strtoupper( $order );
698 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
699 $order = 'DESC';
700 }
701
702 // Build query based on filters.
703 // Note: Renewal records (donation_type = 'renewal') are intentionally included in the listing.
704 // They are shown alongside parent subscriptions so admins can see all transaction activity.
705 // Renewals are also accessible from the parent donation's subscription detail billing history.
706 $has_status = 'all' !== $status;
707 $has_campaign = $campaign_id > 0;
708 $has_search = ! empty( $search );
709 $is_asc = 'ASC' === $order;
710
711 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
712
713 // All three filters.
714 if ( $has_status && $has_campaign && $has_search ) {
715 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
716 $results = $is_asc
717 ? $wpdb->get_results(
718 $wpdb->prepare(
719 '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',
720 $table,
721 sanitize_text_field( $status ),
722 absint( $campaign_id ),
723 $search_term,
724 $search_term,
725 $search_term,
726 $orderby,
727 absint( $offset ),
728 absint( $limit )
729 ),
730 ARRAY_A
731 )
732 : $wpdb->get_results(
733 $wpdb->prepare(
734 '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',
735 $table,
736 sanitize_text_field( $status ),
737 absint( $campaign_id ),
738 $search_term,
739 $search_term,
740 $search_term,
741 $orderby,
742 absint( $offset ),
743 absint( $limit )
744 ),
745 ARRAY_A
746 );
747 } elseif ( $has_status && $has_campaign ) {
748 $results = $is_asc
749 ? $wpdb->get_results(
750 $wpdb->prepare(
751 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
752 $table,
753 sanitize_text_field( $status ),
754 absint( $campaign_id ),
755 $orderby,
756 absint( $offset ),
757 absint( $limit )
758 ),
759 ARRAY_A
760 )
761 : $wpdb->get_results(
762 $wpdb->prepare(
763 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
764 $table,
765 sanitize_text_field( $status ),
766 absint( $campaign_id ),
767 $orderby,
768 absint( $offset ),
769 absint( $limit )
770 ),
771 ARRAY_A
772 );
773 } elseif ( $has_status && $has_search ) {
774 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
775 $results = $is_asc
776 ? $wpdb->get_results(
777 $wpdb->prepare(
778 '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',
779 $table,
780 sanitize_text_field( $status ),
781 $search_term,
782 $search_term,
783 $search_term,
784 $orderby,
785 absint( $offset ),
786 absint( $limit )
787 ),
788 ARRAY_A
789 )
790 : $wpdb->get_results(
791 $wpdb->prepare(
792 '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',
793 $table,
794 sanitize_text_field( $status ),
795 $search_term,
796 $search_term,
797 $search_term,
798 $orderby,
799 absint( $offset ),
800 absint( $limit )
801 ),
802 ARRAY_A
803 );
804 } elseif ( $has_campaign && $has_search ) {
805 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
806 $results = $is_asc
807 ? $wpdb->get_results(
808 $wpdb->prepare(
809 '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',
810 $table,
811 absint( $campaign_id ),
812 $search_term,
813 $search_term,
814 $search_term,
815 $orderby,
816 absint( $offset ),
817 absint( $limit )
818 ),
819 ARRAY_A
820 )
821 : $wpdb->get_results(
822 $wpdb->prepare(
823 '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',
824 $table,
825 absint( $campaign_id ),
826 $search_term,
827 $search_term,
828 $search_term,
829 $orderby,
830 absint( $offset ),
831 absint( $limit )
832 ),
833 ARRAY_A
834 );
835 } elseif ( $has_status ) {
836 $results = $is_asc
837 ? $wpdb->get_results(
838 $wpdb->prepare(
839 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
840 $table,
841 sanitize_text_field( $status ),
842 $orderby,
843 absint( $offset ),
844 absint( $limit )
845 ),
846 ARRAY_A
847 )
848 : $wpdb->get_results(
849 $wpdb->prepare(
850 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
851 $table,
852 sanitize_text_field( $status ),
853 $orderby,
854 absint( $offset ),
855 absint( $limit )
856 ),
857 ARRAY_A
858 );
859 } elseif ( $has_campaign ) {
860 $results = $is_asc
861 ? $wpdb->get_results(
862 $wpdb->prepare(
863 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
864 $table,
865 absint( $campaign_id ),
866 $orderby,
867 absint( $offset ),
868 absint( $limit )
869 ),
870 ARRAY_A
871 )
872 : $wpdb->get_results(
873 $wpdb->prepare(
874 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
875 $table,
876 absint( $campaign_id ),
877 $orderby,
878 absint( $offset ),
879 absint( $limit )
880 ),
881 ARRAY_A
882 );
883 } elseif ( $has_search ) {
884 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
885 $results = $is_asc
886 ? $wpdb->get_results(
887 $wpdb->prepare(
888 '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',
889 $table,
890 $search_term,
891 $search_term,
892 $search_term,
893 $orderby,
894 absint( $offset ),
895 absint( $limit )
896 ),
897 ARRAY_A
898 )
899 : $wpdb->get_results(
900 $wpdb->prepare(
901 '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',
902 $table,
903 $search_term,
904 $search_term,
905 $search_term,
906 $orderby,
907 absint( $offset ),
908 absint( $limit )
909 ),
910 ARRAY_A
911 );
912 } else {
913 $results = $is_asc
914 ? $wpdb->get_results(
915 $wpdb->prepare(
916 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
917 $table,
918 $orderby,
919 absint( $offset ),
920 absint( $limit )
921 ),
922 ARRAY_A
923 )
924 : $wpdb->get_results(
925 $wpdb->prepare(
926 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
927 $table,
928 $orderby,
929 absint( $offset ),
930 absint( $limit )
931 ),
932 ARRAY_A
933 );
934 }
935
936 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
937
938 if ( ! $results || ! is_array( $results ) ) {
939 return [];
940 }
941
942 return array_map( [ $instance, 'decode_by_datatype' ], $results );
943 }
944
945 /**
946 * Build the WHERE clause + prepare-args for an export query.
947 *
948 * Always constrains to one-time donations (subscription_id = '' AND
949 * parent_subscription_id = 0) so recurring/renewal rows never leak into the
950 * free export — recurring export is Pro (see the Import & Export spec, #237).
951 * Optional filters: status, campaign_id, payment_mode, gateway, and a
952 * created_at date range (after / before).
953 *
954 * @param array<string, mixed> $filters Filter map.
955 * @param array<int, mixed> $args Prepare-args, populated by reference in placeholder order.
956 * @return string WHERE clause (without the "WHERE" keyword); placeholders only, no interpolated values.
957 * @since 1.3.0
958 */
959 private static function build_export_where( $filters, &$args ) {
960 $conditions = [ '1=1' ];
961
962 /**
963 * Whether the donations export is restricted to one-time donations.
964 *
965 * True by default so recurring/renewal rows never leak into the free
966 * export; Pro returns false to include subscriptions and renewals.
967 *
968 * @param bool $one_time_only Whether to restrict to one-time donations.
969 */
970 if ( apply_filters( 'suredonation_export_one_time_only', true ) ) {
971 $conditions[] = 'subscription_id = %s';
972 $conditions[] = 'parent_subscription_id = %d';
973 $args[] = '';
974 $args[] = 0;
975 }
976
977 $status = sanitize_text_field( Helper::get_string_value( $filters['status'] ?? '' ) );
978 if ( '' !== $status && 'all' !== $status ) {
979 $conditions[] = 'payment_status = %s';
980 $args[] = $status;
981 }
982
983 $campaign_id = absint( Helper::get_string_value( $filters['campaign_id'] ?? 0 ) );
984 if ( $campaign_id > 0 ) {
985 $conditions[] = 'campaign_id = %d';
986 $args[] = $campaign_id;
987 }
988
989 $payment_mode = sanitize_text_field( Helper::get_string_value( $filters['payment_mode'] ?? '' ) );
990 if ( '' !== $payment_mode ) {
991 $conditions[] = 'payment_mode = %s';
992 $args[] = $payment_mode;
993 }
994
995 $gateway = sanitize_text_field( Helper::get_string_value( $filters['gateway'] ?? '' ) );
996 if ( '' !== $gateway ) {
997 $conditions[] = 'gateway = %s';
998 $args[] = $gateway;
999 }
1000
1001 $after = sanitize_text_field( Helper::get_string_value( $filters['after'] ?? '' ) );
1002 if ( '' !== $after ) {
1003 $conditions[] = 'created_at >= %s';
1004 $args[] = $after;
1005 }
1006
1007 $before = sanitize_text_field( Helper::get_string_value( $filters['before'] ?? '' ) );
1008 if ( '' !== $before ) {
1009 // A date-only `before` (Y-m-d) coerces to 00:00:00, which would
1010 // silently drop donations made later that same day. Normalize to
1011 // end-of-day so the whole end date is inclusive; full datetimes
1012 // are left untouched.
1013 if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $before ) ) {
1014 $before .= ' 23:59:59';
1015 }
1016 $conditions[] = 'created_at <= %s';
1017 $args[] = $before;
1018 }
1019
1020 return implode( ' AND ', $conditions );
1021 }
1022
1023 /**
1024 * Count one-time donations matching the export filters.
1025 *
1026 * @param array<string, mixed> $filters Filter map (see build_export_where()).
1027 * @return int Matching row count.
1028 * @since 1.3.0
1029 */
1030 public static function count_for_export( $filters = [] ) {
1031 $instance = self::get_instance();
1032 global $wpdb;
1033 $table = $instance->get_tablename();
1034
1035 $args = [];
1036 $where = self::build_export_where( $filters, $args );
1037
1038 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export count over live data.
1039 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args.
1040 $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( [ $table ], $args ) ) );
1041 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1042
1043 return is_numeric( $count ) ? (int) $count : 0;
1044 }
1045
1046 /**
1047 * Fetch one-time donations for export, decoded.
1048 *
1049 * @param array<string, mixed> $filters Filter map (see build_export_where()).
1050 * @param int $limit Max rows to return (0 = no limit).
1051 * @param int $offset Offset for pagination.
1052 * @return array<int, array<string, mixed>> Decoded donation rows.
1053 * @since 1.3.0
1054 */
1055 public static function get_for_export( $filters = [], $limit = 0, $offset = 0 ) {
1056 $instance = self::get_instance();
1057 global $wpdb;
1058 $table = $instance->get_tablename();
1059
1060 $args = [];
1061 $where = self::build_export_where( $filters, $args );
1062
1063 $sql = "SELECT * FROM %i WHERE {$where} ORDER BY created_at DESC";
1064 $prepare_args = array_merge( [ $table ], $args );
1065
1066 if ( $limit > 0 ) {
1067 $sql .= ' LIMIT %d, %d';
1068 $prepare_args[] = absint( $offset );
1069 $prepare_args[] = absint( $limit );
1070 }
1071
1072 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export query over live data.
1073 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $sql is assembled only from static placeholder fragments; every value is passed through prepare args.
1074 $results = $wpdb->get_results( $wpdb->prepare( $sql, $prepare_args ), ARRAY_A );
1075 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1076
1077 if ( ! $results || ! is_array( $results ) ) {
1078 return [];
1079 }
1080
1081 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1082 }
1083
1084 /**
1085 * Get donations by status with pagination.
1086 *
1087 * @param string $status Payment status.
1088 * @param int $limit Number of records to return.
1089 * @param int $offset Offset for pagination.
1090 * @param string $orderby Column to order by.
1091 * @param string $order Order direction (ASC or DESC).
1092 * @return array<mixed> Array of donations.
1093 * @since 0.0.1
1094 */
1095 public static function get_by_status( $status, $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
1096 $instance = self::get_instance();
1097 global $wpdb;
1098 $table = $instance->get_tablename();
1099
1100 // Validate orderby column.
1101 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
1102 $orderby = 'created_at';
1103 }
1104
1105 // Validate order direction.
1106 $order = strtoupper( $order );
1107 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
1108 $order = 'DESC';
1109 }
1110
1111 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
1112 $results = 'ASC' === $order
1113 ? $wpdb->get_results(
1114 $wpdb->prepare(
1115 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
1116 $table,
1117 sanitize_text_field( $status ),
1118 $orderby,
1119 absint( $offset ),
1120 absint( $limit )
1121 ),
1122 ARRAY_A
1123 )
1124 : $wpdb->get_results(
1125 $wpdb->prepare(
1126 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
1127 $table,
1128 sanitize_text_field( $status ),
1129 $orderby,
1130 absint( $offset ),
1131 absint( $limit )
1132 ),
1133 ARRAY_A
1134 );
1135 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1136
1137 if ( ! $results || ! is_array( $results ) ) {
1138 return [];
1139 }
1140
1141 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1142 }
1143
1144 /**
1145 * Get donations by campaign ID with pagination.
1146 *
1147 * @param int $campaign_id Campaign ID.
1148 * @param int $limit Number of records to return.
1149 * @param int $offset Offset for pagination.
1150 * @param string $orderby Column to order by.
1151 * @param string $order Order direction (ASC or DESC).
1152 * @return array<mixed> Array of donations.
1153 * @since 0.0.1
1154 */
1155 public static function get_by_campaign_id( $campaign_id, $limit = 100, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
1156 if ( empty( $campaign_id ) ) {
1157 return [];
1158 }
1159
1160 $instance = self::get_instance();
1161 global $wpdb;
1162 $table = $instance->get_tablename();
1163
1164 // Validate orderby column.
1165 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
1166 $orderby = 'created_at';
1167 }
1168
1169 // Validate order direction.
1170 $order = strtoupper( $order );
1171 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
1172 $order = 'DESC';
1173 }
1174
1175 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
1176 $results = 'ASC' === $order
1177 ? $wpdb->get_results(
1178 $wpdb->prepare(
1179 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
1180 $table,
1181 absint( $campaign_id ),
1182 $orderby,
1183 absint( $offset ),
1184 absint( $limit )
1185 ),
1186 ARRAY_A
1187 )
1188 : $wpdb->get_results(
1189 $wpdb->prepare(
1190 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
1191 $table,
1192 absint( $campaign_id ),
1193 $orderby,
1194 absint( $offset ),
1195 absint( $limit )
1196 ),
1197 ARRAY_A
1198 );
1199 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1200
1201 if ( ! $results || ! is_array( $results ) ) {
1202 return [];
1203 }
1204
1205 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1206 }
1207
1208 /**
1209 * Delete a donation record.
1210 *
1211 * @param int $donation_id Donation ID.
1212 * @return int|false Number of rows deleted or false on error.
1213 * @since 0.0.1
1214 */
1215 public static function delete( $donation_id ) {
1216 if ( empty( $donation_id ) ) {
1217 return false;
1218 }
1219
1220 return self::get_instance()->use_delete( [ 'id' => absint( $donation_id ) ] );
1221 }
1222
1223 /**
1224 * Get donations by donor email.
1225 *
1226 * @param string $email Donor email.
1227 * @param int $limit Max rows to return; 0 (default) returns all rows.
1228 * @param int $offset Row offset, applied only when $limit > 0.
1229 * @return array<mixed> Array of donations.
1230 * @since 0.0.1
1231 */
1232 public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) {
1233 if ( empty( $email ) ) {
1234 return [];
1235 }
1236
1237 $instance = self::get_instance();
1238 global $wpdb;
1239
1240 $limit = max( 0, (int) $limit );
1241 $offset = max( 0, (int) $offset );
1242
1243 if ( $limit > 0 ) {
1244 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1245 $results = $wpdb->get_results(
1246 $wpdb->prepare(
1247 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d',
1248 $instance->get_tablename(),
1249 sanitize_email( $email ),
1250 $limit,
1251 $offset
1252 ),
1253 ARRAY_A
1254 );
1255 } else {
1256 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1257 $results = $wpdb->get_results(
1258 $wpdb->prepare(
1259 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC',
1260 $instance->get_tablename(),
1261 sanitize_email( $email )
1262 ),
1263 ARRAY_A
1264 );
1265 }
1266
1267 if ( ! $results || ! is_array( $results ) ) {
1268 return [];
1269 }
1270
1271 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1272 }
1273
1274 /**
1275 * Get donation by transaction ID.
1276 *
1277 * @param string $transaction_id Transaction ID.
1278 * @return array<string, mixed>|null Donation data or null if not found.
1279 * @since 0.0.1
1280 */
1281 public static function get_by_transaction_id( $transaction_id ) {
1282 if ( empty( $transaction_id ) ) {
1283 return null;
1284 }
1285
1286 $instance = self::get_instance();
1287 global $wpdb;
1288
1289 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1290 $result = $wpdb->get_row(
1291 $wpdb->prepare(
1292 'SELECT * FROM %i WHERE transaction_id = %s LIMIT 1',
1293 $instance->get_tablename(),
1294 sanitize_text_field( $transaction_id )
1295 ),
1296 ARRAY_A
1297 );
1298
1299 if ( ! $result ) {
1300 return null;
1301 }
1302
1303 return $instance->decode_by_datatype( $result );
1304 }
1305
1306 /**
1307 * Get total donations count (no filters).
1308 *
1309 * @return int Total count.
1310 * @since 0.0.1
1311 */
1312 public static function count_all() {
1313 $instance = self::get_instance();
1314 global $wpdb;
1315
1316 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1317 $count = $wpdb->get_var(
1318 $wpdb->prepare(
1319 'SELECT COUNT(*) FROM %i',
1320 $instance->get_tablename()
1321 )
1322 );
1323
1324 return is_numeric( $count ) ? (int) $count : 0;
1325 }
1326
1327 /**
1328 * Get total donations count by payment status.
1329 *
1330 * @param string $status Payment status.
1331 * @return int Total count.
1332 * @since 0.0.1
1333 */
1334 public static function count_by_status( $status ) {
1335 $instance = self::get_instance();
1336 global $wpdb;
1337
1338 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1339 $count = $wpdb->get_var(
1340 $wpdb->prepare(
1341 'SELECT COUNT(*) FROM %i WHERE payment_status = %s',
1342 $instance->get_tablename(),
1343 sanitize_text_field( $status )
1344 )
1345 );
1346
1347 return is_numeric( $count ) ? (int) $count : 0;
1348 }
1349
1350 /**
1351 * Get the count of completed, live-mode donations.
1352 *
1353 * Used to gate the review admin notice: a completed live donation is the
1354 * signal that the site has taken a genuine (non-test) donation.
1355 *
1356 * @return int Count of completed live donations.
1357 * @since 1.2.0
1358 */
1359 public static function count_live_completed() {
1360 $instance = self::get_instance();
1361 global $wpdb;
1362
1363 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1364 $count = $wpdb->get_var(
1365 $wpdb->prepare(
1366 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s',
1367 $instance->get_tablename(),
1368 'completed',
1369 'live'
1370 )
1371 );
1372
1373 return is_numeric( $count ) ? (int) $count : 0;
1374 }
1375
1376 /**
1377 * Get total donations count by campaign.
1378 *
1379 * @param int $campaign_id Campaign ID.
1380 * @return int Total count.
1381 * @since 0.0.1
1382 */
1383 public static function count_by_campaign( $campaign_id ) {
1384 $instance = self::get_instance();
1385 global $wpdb;
1386
1387 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1388 $count = $wpdb->get_var(
1389 $wpdb->prepare(
1390 'SELECT COUNT(*) FROM %i WHERE campaign_id = %d',
1391 $instance->get_tablename(),
1392 absint( $campaign_id )
1393 )
1394 );
1395
1396 return is_numeric( $count ) ? (int) $count : 0;
1397 }
1398
1399 /**
1400 * Get total donations count by status and campaign.
1401 *
1402 * @param string $status Payment status ('all' for no filter).
1403 * @param int $campaign_id Optional campaign ID (0 for no filter).
1404 * @return int Total count.
1405 * @since 0.0.1
1406 */
1407 public static function get_total_donations_by_status( $status = 'all', $campaign_id = 0 ) {
1408 $instance = self::get_instance();
1409 global $wpdb;
1410
1411 // Both filters.
1412 if ( 'all' !== $status && $campaign_id > 0 ) {
1413 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1414 $count = $wpdb->get_var(
1415 $wpdb->prepare(
1416 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND campaign_id = %d',
1417 $instance->get_tablename(),
1418 sanitize_text_field( $status ),
1419 absint( $campaign_id )
1420 )
1421 );
1422 return is_numeric( $count ) ? (int) $count : 0;
1423 }
1424
1425 // Status filter only.
1426 if ( 'all' !== $status ) {
1427 return self::count_by_status( $status );
1428 }
1429
1430 // Campaign filter only.
1431 if ( $campaign_id > 0 ) {
1432 return self::count_by_campaign( $campaign_id );
1433 }
1434
1435 // No filters.
1436 return self::count_all();
1437 }
1438
1439 /**
1440 * Get campaign statistics.
1441 *
1442 * @param int $campaign_id Campaign ID.
1443 * @return array<string,mixed> Campaign statistics.
1444 * @since 0.0.1
1445 */
1446 public static function get_campaign_stats( $campaign_id ) {
1447 $instance = self::get_instance();
1448 global $wpdb;
1449
1450 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1451 $stats = $wpdb->get_row(
1452 $wpdb->prepare(
1453 "SELECT
1454 COUNT(*) as donation_count,
1455 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1456 COUNT(DISTINCT donor_email) as unique_donors,
1457 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1458 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1459 FROM %i
1460 WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded')",
1461 $instance->get_tablename(),
1462 absint( $campaign_id )
1463 ),
1464 ARRAY_A
1465 );
1466
1467 return $stats ? $stats : [
1468 'donation_count' => 0,
1469 'total_raised' => 0,
1470 'unique_donors' => 0,
1471 'average_donation' => 0,
1472 'largest_donation' => 0,
1473 ];
1474 }
1475
1476 /**
1477 * Get global dashboard statistics.
1478 *
1479 * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics.
1480 * @since 0.0.1
1481 */
1482 public static function get_dashboard_stats() {
1483 $instance = self::get_instance();
1484 global $wpdb;
1485
1486 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1487 $stats = $wpdb->get_row(
1488 $wpdb->prepare(
1489 "SELECT
1490 COUNT(*) as total_donations,
1491 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1492 COUNT(DISTINCT donor_email) as unique_donors,
1493 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1494 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1495 FROM %i
1496 WHERE payment_status IN ('completed', 'partially_refunded')",
1497 $instance->get_tablename()
1498 ),
1499 ARRAY_A
1500 );
1501
1502 return $stats ? $stats : [
1503 'total_donations' => 0,
1504 'total_raised' => 0,
1505 'unique_donors' => 0,
1506 'average_donation' => 0,
1507 'largest_donation' => 0,
1508 ];
1509 }
1510
1511 /**
1512 * Get recent donations globally (all campaigns).
1513 *
1514 * @param int $limit Number of donations to retrieve.
1515 * @return array<int, array<string, mixed>> Array of recent donations.
1516 * @since 0.0.1
1517 */
1518 public static function get_recent_donations_global( $limit = 5 ) {
1519 $instance = self::get_instance();
1520 global $wpdb;
1521
1522 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1523 $results = $wpdb->get_results(
1524 $wpdb->prepare(
1525 "SELECT * FROM %i WHERE payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
1526 $instance->get_tablename(),
1527 absint( $limit )
1528 ),
1529 ARRAY_A
1530 );
1531
1532 if ( ! $results || ! is_array( $results ) ) {
1533 return [];
1534 }
1535
1536 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1537 }
1538
1539 /**
1540 * Get top campaigns by donations.
1541 *
1542 * @param int $limit Number of campaigns to retrieve.
1543 * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats.
1544 * @since 0.0.1
1545 */
1546 public static function get_top_campaigns( $limit = 5 ) {
1547 $instance = self::get_instance();
1548 global $wpdb;
1549
1550 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1551 $results = $wpdb->get_results(
1552 $wpdb->prepare(
1553 "SELECT
1554 campaign_id,
1555 COUNT(*) as donation_count,
1556 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1557 COUNT(DISTINCT donor_email) as unique_donors
1558 FROM %i
1559 WHERE payment_status IN ('completed', 'partially_refunded')
1560 GROUP BY campaign_id
1561 ORDER BY total_raised DESC
1562 LIMIT %d",
1563 $instance->get_tablename(),
1564 absint( $limit )
1565 ),
1566 ARRAY_A
1567 );
1568
1569 return $results ? $results : [];
1570 }
1571
1572 /**
1573 * Get donation trends over time.
1574 *
1575 * @param string $after Start date (ISO format).
1576 * @param string $before End date (ISO format).
1577 * @param string $group Grouping: 'day', 'week', or 'month'.
1578 * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends.
1579 * @since 0.0.1
1580 */
1581 public static function get_donation_trends( $after = '', $before = '', $group = 'day' ) {
1582 $instance = self::get_instance();
1583 global $wpdb;
1584
1585 // Default to last 30 days if no dates provided.
1586 if ( empty( $after ) ) {
1587 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
1588 }
1589 if ( empty( $before ) ) {
1590 $before = gmdate( 'Y-m-d' );
1591 }
1592
1593 // Determine date format based on grouping.
1594 switch ( $group ) {
1595 case 'month':
1596 $date_format = '%Y-%m-01';
1597 break;
1598 case 'week':
1599 $date_format = '%x-%v'; // ISO year-week.
1600 break;
1601 case 'day':
1602 default:
1603 $date_format = '%Y-%m-%d';
1604 break;
1605 }
1606
1607 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1608 $results = $wpdb->get_results(
1609 $wpdb->prepare(
1610 "SELECT
1611 DATE_FORMAT(created_at, %s) as period,
1612 COUNT(*) as donation_count,
1613 COALESCE(SUM(amount - refunded_amount), 0) as total_amount
1614 FROM %i
1615 WHERE payment_status IN ('completed', 'partially_refunded')
1616 AND DATE(created_at) >= %s
1617 AND DATE(created_at) <= %s
1618 GROUP BY period
1619 ORDER BY period ASC",
1620 $date_format,
1621 $instance->get_tablename(),
1622 $after,
1623 $before
1624 ),
1625 ARRAY_A
1626 );
1627
1628 return $results ? $results : [];
1629 }
1630
1631 /**
1632 * Get recent donations for a campaign.
1633 *
1634 * @param int $campaign_id Campaign ID.
1635 * @param int $limit Number of donations to retrieve.
1636 * @return array<mixed> Array of recent donations.
1637 * @since 0.0.1
1638 */
1639 public static function get_recent_donations( $campaign_id, $limit = 5 ) {
1640 $instance = self::get_instance();
1641 global $wpdb;
1642
1643 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1644 $results = $wpdb->get_results(
1645 $wpdb->prepare(
1646 "SELECT * FROM %i WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
1647 $instance->get_tablename(),
1648 absint( $campaign_id ),
1649 absint( $limit )
1650 ),
1651 ARRAY_A
1652 );
1653
1654 if ( ! $results || ! is_array( $results ) ) {
1655 return [];
1656 }
1657
1658 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1659 }
1660
1661 /**
1662 * Get paginated donations for a specific donor.
1663 *
1664 * @param int $donor_id Donor ID.
1665 * @param int $limit Number of records to return.
1666 * @param int $offset Offset for pagination.
1667 * @return array{donations: array<int, array<string, mixed>>, total: int} Paginated donations and total count.
1668 * @since 1.0.0
1669 */
1670 public static function get_by_donor_id( $donor_id, $limit = 10, $offset = 0 ) {
1671 if ( empty( $donor_id ) ) {
1672 return [
1673 'donations' => [],
1674 'total' => 0,
1675 ];
1676 }
1677
1678 $instance = self::get_instance();
1679 global $wpdb;
1680 $table = $instance->get_tablename();
1681
1682 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1683
1684 $total = $wpdb->get_var(
1685 $wpdb->prepare(
1686 'SELECT COUNT(*) FROM %i WHERE donor_id = %d',
1687 $table,
1688 absint( $donor_id )
1689 )
1690 );
1691
1692 $results = $wpdb->get_results(
1693 $wpdb->prepare(
1694 'SELECT * FROM %i WHERE donor_id = %d ORDER BY created_at DESC LIMIT %d, %d',
1695 $table,
1696 absint( $donor_id ),
1697 absint( $offset ),
1698 absint( $limit )
1699 ),
1700 ARRAY_A
1701 );
1702
1703 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1704
1705 if ( ! $results || ! is_array( $results ) ) {
1706 $results = [];
1707 }
1708
1709 return [
1710 'donations' => array_map( [ $instance, 'decode_by_datatype' ], $results ),
1711 'total' => is_numeric( $total ) ? (int) $total : 0,
1712 ];
1713 }
1714
1715 /**
1716 * Get donation activity data for a specific donor (for chart).
1717 *
1718 * @param int $donor_id Donor ID.
1719 * @param string $after Start date (Y-m-d).
1720 * @param string $before End date (Y-m-d).
1721 * @return array{chart_data: array<int, array{date: string, amount: float}>, stats: array{lifetime: float, highest: float, average: float}} Activity data.
1722 * @since 1.0.0
1723 */
1724 public static function get_donor_activity( $donor_id, $after = '', $before = '' ) {
1725 if ( empty( $donor_id ) ) {
1726 return [
1727 'chart_data' => [],
1728 'stats' => [
1729 'lifetime' => 0,
1730 'highest' => 0,
1731 'average' => 0,
1732 ],
1733 ];
1734 }
1735
1736 $instance = self::get_instance();
1737 global $wpdb;
1738 $table = $instance->get_tablename();
1739
1740 // Default date range: last 30 days.
1741 if ( empty( $after ) ) {
1742 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
1743 }
1744 if ( empty( $before ) ) {
1745 $before = gmdate( 'Y-m-d' );
1746 }
1747
1748 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1749
1750 // Chart data: donations grouped by date.
1751 $chart_data = $wpdb->get_results(
1752 $wpdb->prepare(
1753 "SELECT DATE(created_at) as date, COALESCE(SUM(amount), 0) as amount
1754 FROM %i
1755 WHERE donor_id = %d
1756 AND payment_status IN ('completed', 'partially_refunded')
1757 AND DATE(created_at) >= %s
1758 AND DATE(created_at) <= %s
1759 GROUP BY DATE(created_at)
1760 ORDER BY date ASC",
1761 $table,
1762 absint( $donor_id ),
1763 $after,
1764 $before
1765 ),
1766 ARRAY_A
1767 );
1768
1769 // Lifetime stats for this donor.
1770 $stats = $wpdb->get_row(
1771 $wpdb->prepare(
1772 "SELECT
1773 COALESCE(SUM(amount - refunded_amount), 0) as lifetime,
1774 COALESCE(MAX(amount), 0) as highest,
1775 COALESCE(AVG(amount), 0) as average
1776 FROM %i
1777 WHERE donor_id = %d AND payment_status IN ('completed', 'partially_refunded')",
1778 $table,
1779 absint( $donor_id )
1780 ),
1781 ARRAY_A
1782 );
1783
1784 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1785
1786 $stats = is_array( $stats ) ? $stats : [];
1787
1788 return [
1789 'chart_data' => is_array( $chart_data ) ? $chart_data : [],
1790 'stats' => [
1791 'lifetime' => is_numeric( $stats['lifetime'] ?? 0 ) ? round( (float) ( $stats['lifetime'] ?? 0 ), 2 ) : 0,
1792 'highest' => is_numeric( $stats['highest'] ?? 0 ) ? round( (float) ( $stats['highest'] ?? 0 ), 2 ) : 0,
1793 'average' => is_numeric( $stats['average'] ?? 0 ) ? round( (float) ( $stats['average'] ?? 0 ), 2 ) : 0,
1794 ],
1795 ];
1796 }
1797
1798 /**
1799 * Update donation status.
1800 *
1801 * @param int $donation_id Donation ID.
1802 * @param string $status New status.
1803 * @return int|false Number of rows updated or false on error.
1804 * @since 0.0.1
1805 */
1806 public static function update_status( $donation_id, $status ) {
1807 if ( empty( $donation_id ) || ! in_array( $status, self::$valid_statuses, true ) ) {
1808 return false;
1809 }
1810
1811 return self::update( $donation_id, [ 'payment_status' => $status ] );
1812 }
1813
1814 /**
1815 * Get valid payment statuses.
1816 *
1817 * @return array<string> Valid statuses.
1818 * @since 0.0.1
1819 */
1820 public static function get_valid_statuses() {
1821 return self::$valid_statuses;
1822 }
1823
1824 /**
1825 * Add a log entry to a donation.
1826 *
1827 * @param int $donation_id Donation ID.
1828 * @param string $action Action type (e.g., 'status_change', 'refund', 'webhook').
1829 * @param string $message Log message.
1830 * @param array<string, mixed> $data Optional additional data.
1831 * @return int|false Number of rows updated or false on error.
1832 * @since 0.0.1
1833 */
1834 public static function add_log( $donation_id, $action, $message, $data = [] ) {
1835 if ( empty( $donation_id ) ) {
1836 return false;
1837 }
1838
1839 $donation = self::get( $donation_id );
1840 if ( ! $donation ) {
1841 return false;
1842 }
1843
1844 // Get existing log or initialize empty array.
1845 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
1846 $log_data = $donation['log'] ?? [];
1847 if ( is_array( $log_data ) ) {
1848 $log = $log_data;
1849 } elseif ( is_string( $log_data ) && ! empty( $log_data ) ) {
1850 $log = json_decode( $log_data, true );
1851 if ( ! is_array( $log ) ) {
1852 $log = [];
1853 }
1854 } else {
1855 $log = [];
1856 }
1857
1858 // Add new log entry.
1859 $log[] = [
1860 'action' => sanitize_text_field( $action ),
1861 'message' => sanitize_text_field( $message ),
1862 'data' => $data,
1863 'timestamp' => current_time( 'mysql' ),
1864 ];
1865
1866 return self::update( $donation_id, [ 'log' => $log ] );
1867 }
1868
1869 /**
1870 * Get log entries for a donation.
1871 *
1872 * @param int $donation_id Donation ID.
1873 * @return array<int, array<string, mixed>> Log entries.
1874 * @since 0.0.1
1875 */
1876 public static function get_log( $donation_id ) {
1877 if ( empty( $donation_id ) ) {
1878 return [];
1879 }
1880
1881 $donation = self::get( $donation_id );
1882 if ( ! $donation || empty( $donation['log'] ) ) {
1883 return [];
1884 }
1885
1886 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
1887 $log_data = $donation['log'];
1888 if ( is_array( $log_data ) ) {
1889 return $log_data;
1890 }
1891
1892 if ( is_string( $log_data ) ) {
1893 $log = json_decode( $log_data, true );
1894 return is_array( $log ) ? $log : [];
1895 }
1896
1897 return [];
1898 }
1899
1900 /**
1901 * Add refund data to donation_data for audit trail and duplicate prevention.
1902 *
1903 * Stores each refund with its ID as the key for O(1) lookups.
1904 *
1905 * @param int $donation_id Donation ID.
1906 * @param array<string, mixed> $refund_data Refund data to store.
1907 * @return bool True on success, false on failure.
1908 * @since 0.0.1
1909 */
1910 public static function add_refund_to_donation_data( $donation_id, $refund_data ) {
1911 $refund_id = $refund_data['refund_id'] ?? '';
1912
1913 if ( empty( $refund_id ) || empty( $donation_id ) ) {
1914 return false;
1915 }
1916
1917 $donation = self::get( $donation_id );
1918 if ( ! $donation ) {
1919 return false;
1920 }
1921
1922 // Get existing donation_data.
1923 $donation_data = $donation['donation_data'] ?? [];
1924 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1925 $donation_data = json_decode( $donation_data, true );
1926 }
1927 if ( ! is_array( $donation_data ) ) {
1928 $donation_data = [];
1929 }
1930
1931 // Initialize refunds array if not exists.
1932 if ( ! isset( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
1933 $donation_data['refunds'] = [];
1934 }
1935
1936 // Store with refund ID as key for O(1) lookup (duplicate prevention).
1937 $donation_data['refunds'][ $refund_id ] = $refund_data;
1938
1939 // Update donation_data in database.
1940 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1941
1942 return false !== $result;
1943 }
1944
1945 /**
1946 * Store the submitted form field values under the donation_data['fields'] key.
1947 *
1948 * The donation_data column is shared JSON (also holds refunds, notes and
1949 * subscription metadata), so the field data is merged under a dedicated
1950 * 'fields' key and never overwrites the column.
1951 *
1952 * Fields are written at donation creation (before the payment is confirmed)
1953 * and are intentionally retained for abandoned/failed donations — pending
1954 * records are legitimate business data (recovery, reconciliation, reporting).
1955 * There is deliberately no automatic PII purge here; erasure is handled on
1956 * demand via the admin delete actions (and can be wired to WordPress's
1957 * personal-data eraser hooks if a retention policy is later required).
1958 *
1959 * @param int $donation_id Donation ID.
1960 * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs.
1961 * @return bool True on success, false on failure.
1962 * @since 1.1.1
1963 */
1964 public static function set_submitted_fields( $donation_id, $field_data ) {
1965 if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) {
1966 return false;
1967 }
1968
1969 $donation = self::get( $donation_id );
1970 if ( ! $donation ) {
1971 return false;
1972 }
1973
1974 // Get existing donation_data.
1975 $donation_data = $donation['donation_data'] ?? [];
1976 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1977 $donation_data = json_decode( $donation_data, true );
1978 }
1979 if ( ! is_array( $donation_data ) ) {
1980 $donation_data = [];
1981 }
1982
1983 // Merge under a dedicated key — never overwrite the shared column.
1984 $donation_data['fields'] = $field_data;
1985
1986 // Update donation_data in database.
1987 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1988
1989 return false !== $result;
1990 }
1991
1992 /**
1993 * Check if a refund already exists in the donation data.
1994 *
1995 * This prevents duplicate processing of the same refund.
1996 *
1997 * @param int $donation_id Donation ID.
1998 * @param string $refund_id Refund ID to check.
1999 * @return bool True if refund already exists, false otherwise.
2000 * @since 0.0.1
2001 */
2002 public static function check_refund_exists( $donation_id, $refund_id ) {
2003 if ( empty( $donation_id ) || empty( $refund_id ) ) {
2004 return false;
2005 }
2006
2007 $donation = self::get( $donation_id );
2008 if ( ! $donation ) {
2009 return false;
2010 }
2011
2012 // Get donation_data and parse if needed.
2013 $donation_data = $donation['donation_data'] ?? [];
2014 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2015 $donation_data = json_decode( $donation_data, true );
2016 }
2017 if ( ! is_array( $donation_data ) ) {
2018 return false;
2019 }
2020
2021 // Check if refunds array exists and contains this refund ID.
2022 if ( empty( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
2023 return false;
2024 }
2025
2026 // O(1) lookup using refund ID as array key.
2027 return isset( $donation_data['refunds'][ $refund_id ] );
2028 }
2029
2030 /**
2031 * Add a note to a donation.
2032 *
2033 * @param int $donation_id Donation ID.
2034 * @param string $note_content Note content.
2035 * @param int $author_id Author user ID.
2036 * @return array{success: bool, note_id: string|null} Result with success status and note ID.
2037 * @since 0.0.1
2038 */
2039 public static function add_note( $donation_id, $note_content, $author_id = 0 ) {
2040 $result = [
2041 'success' => false,
2042 'note_id' => null,
2043 ];
2044
2045 if ( empty( $donation_id ) || empty( $note_content ) ) {
2046 return $result;
2047 }
2048
2049 $donation = self::get( $donation_id );
2050 if ( ! $donation ) {
2051 return $result;
2052 }
2053
2054 // Get existing donation_data.
2055 $donation_data = $donation['donation_data'] ?? [];
2056 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2057 $donation_data = json_decode( $donation_data, true );
2058 }
2059 if ( ! is_array( $donation_data ) ) {
2060 $donation_data = [];
2061 }
2062
2063 // Initialize notes array if not exists.
2064 if ( ! isset( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
2065 $donation_data['notes'] = [];
2066 }
2067
2068 // Generate unique note ID.
2069 $note_id = uniqid( 'note_', true );
2070
2071 // Get author info.
2072 $author_name = __( 'System', 'suredonation' );
2073 if ( $author_id > 0 ) {
2074 $user = get_userdata( $author_id );
2075 if ( $user ) {
2076 $author_name = $user->display_name;
2077 }
2078 }
2079
2080 // Add new note.
2081 $donation_data['notes'][ $note_id ] = [
2082 'id' => $note_id,
2083 'content' => wp_kses_post( $note_content ),
2084 'author_id' => $author_id,
2085 'author_name' => $author_name,
2086 'created_at' => current_time( 'mysql' ),
2087 ];
2088
2089 // Update donation_data in database.
2090 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2091
2092 if ( false !== $update_result ) {
2093 $result['success'] = true;
2094 $result['note_id'] = $note_id;
2095 }
2096
2097 return $result;
2098 }
2099
2100 /**
2101 * Get notes for a donation with pagination.
2102 *
2103 * @param int $donation_id Donation ID.
2104 * @param int $page Current page (1-indexed).
2105 * @param int $per_page Notes per page.
2106 * @return array{notes: array<int, array<string, mixed>>, total: int, total_pages: int} Paginated notes.
2107 * @since 0.0.1
2108 */
2109 public static function get_notes( $donation_id, $page = 1, $per_page = 3 ) {
2110 $result = [
2111 'notes' => [],
2112 'total' => 0,
2113 'total_pages' => 0,
2114 ];
2115
2116 if ( empty( $donation_id ) ) {
2117 return $result;
2118 }
2119
2120 $donation = self::get( $donation_id );
2121 if ( ! $donation ) {
2122 return $result;
2123 }
2124
2125 // Get donation_data and parse if needed.
2126 $donation_data = $donation['donation_data'] ?? [];
2127 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2128 $donation_data = json_decode( $donation_data, true );
2129 }
2130 if ( ! is_array( $donation_data ) ) {
2131 return $result;
2132 }
2133
2134 // Get notes array.
2135 if ( empty( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
2136 return $result;
2137 }
2138
2139 // Convert to array values and sort by created_at (newest first).
2140 $all_notes = array_values( $donation_data['notes'] );
2141 usort(
2142 $all_notes,
2143 static function ( $a, $b ) {
2144 return strtotime( $b['created_at'] ?? '0' ) - strtotime( $a['created_at'] ?? '0' );
2145 }
2146 );
2147
2148 $total = count( $all_notes );
2149 $total_pages = (int) ceil( $total / $per_page );
2150 $offset = ( $page - 1 ) * $per_page;
2151
2152 // Get paginated notes.
2153 $notes = array_slice( $all_notes, $offset, $per_page );
2154
2155 return [
2156 'notes' => $notes,
2157 'total' => $total,
2158 'total_pages' => $total_pages,
2159 ];
2160 }
2161
2162 /**
2163 * Delete a note from a donation.
2164 *
2165 * @param int $donation_id Donation ID.
2166 * @param string $note_id Note ID to delete.
2167 * @return bool True on success, false on failure.
2168 * @since 0.0.1
2169 */
2170 public static function delete_note( $donation_id, $note_id ) {
2171 if ( empty( $donation_id ) || empty( $note_id ) ) {
2172 return false;
2173 }
2174
2175 $donation = self::get( $donation_id );
2176 if ( ! $donation ) {
2177 return false;
2178 }
2179
2180 // Get donation_data and parse if needed.
2181 $donation_data = $donation['donation_data'] ?? [];
2182 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2183 $donation_data = json_decode( $donation_data, true );
2184 }
2185 if ( ! is_array( $donation_data ) ) {
2186 return false;
2187 }
2188
2189 // Check if note exists.
2190 if ( empty( $donation_data['notes'] ) || ! isset( $donation_data['notes'][ $note_id ] ) ) {
2191 return false;
2192 }
2193
2194 // Remove the note.
2195 unset( $donation_data['notes'][ $note_id ] );
2196
2197 // Update donation_data in database.
2198 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2199
2200 return false !== $result;
2201 }
2202
2203 /**
2204 * Remove a refund from donation_data.
2205 *
2206 * Used when a refund is canceled.
2207 *
2208 * @param int $donation_id Donation ID.
2209 * @param string $refund_id Refund ID to remove.
2210 * @return array{removed: bool, refund_data: array<string, mixed>|null} Result with removed status and refund data.
2211 * @since 0.0.1
2212 */
2213 public static function remove_refund_from_donation_data( $donation_id, $refund_id ) {
2214 $result = [
2215 'removed' => false,
2216 'refund_data' => null,
2217 ];
2218
2219 if ( empty( $donation_id ) || empty( $refund_id ) ) {
2220 return $result;
2221 }
2222
2223 $donation = self::get( $donation_id );
2224 if ( ! $donation ) {
2225 return $result;
2226 }
2227
2228 // Get donation_data and parse if needed.
2229 $donation_data = $donation['donation_data'] ?? [];
2230 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2231 $donation_data = json_decode( $donation_data, true );
2232 }
2233 if ( ! is_array( $donation_data ) ) {
2234 return $result;
2235 }
2236
2237 // Check if refund exists.
2238 if ( empty( $donation_data['refunds'] ) || ! isset( $donation_data['refunds'][ $refund_id ] ) ) {
2239 return $result;
2240 }
2241
2242 // Store the refund data before removing.
2243 $result['refund_data'] = $donation_data['refunds'][ $refund_id ];
2244
2245 // Remove the refund.
2246 unset( $donation_data['refunds'][ $refund_id ] );
2247
2248 // Update donation_data in database.
2249 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2250
2251 $result['removed'] = false !== $update_result;
2252
2253 return $result;
2254 }
2255 }
2256