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

2,773 lines 88.8 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 = 7;
41
42 /**
43 * Valid donor-comment moderation statuses.
44 *
45 * `approved` comments are public; `pending` is awaiting review (only reachable
46 * when the "Hold donor comments for review" setting is on); `rejected` is
47 * hidden but kept, so a moderator's decision is not destructive.
48 *
49 * @var array<string>
50 * @since 1.6.0
51 */
52 private static $valid_comment_statuses = [
53 'approved',
54 'pending',
55 'rejected',
56 ];
57
58 /**
59 * Valid payment statuses.
60 *
61 * @var array<string>
62 * @since 0.0.1
63 */
64 private static $valid_statuses = [
65 'pending',
66 'processing',
67 'completed',
68 'failed',
69 'refunded',
70 'partially_refunded',
71 'cancelled',
72 'suspicious',
73 // Deliberately not 'failed'. A donor who closed the gateway window never
74 // attempted a payment, and collapsing the two destroys the signal we most
75 // need: our own capture failure rate. If abandonment and genuine failures
76 // share a status, "most donors walk away at the gateway" (a product
77 // problem) is indistinguishable from "our captures are breaking" (a bug).
78 'abandoned',
79 ];
80
81 /**
82 * Valid order columns.
83 *
84 * @var array<string>
85 * @since 0.0.1
86 */
87 private static $valid_order_columns = [
88 'id',
89 'campaign_id',
90 'amount',
91 'created_at',
92 'updated_at',
93 'payment_status',
94 'donor_name',
95 'donor_email',
96 'subscription_status',
97 'subscription_id',
98 ];
99
100 /**
101 * {@inheritDoc}
102 */
103 public function get_schema() {
104 return [
105 'id' => [
106 'type' => 'number',
107 ],
108 'campaign_id' => [
109 'type' => 'number',
110 ],
111 'donor_id' => [
112 'type' => 'number',
113 'default' => 0,
114 ],
115 'form_id' => [
116 'type' => 'number',
117 'default' => 0,
118 ],
119 'amount' => [
120 'type' => 'string',
121 'default' => '0.00000000',
122 ],
123 'fees_covered' => [
124 'type' => 'string',
125 'default' => '0.00000000',
126 ],
127 'refunded_amount' => [
128 'type' => 'string',
129 'default' => '0.00000000',
130 ],
131 'currency' => [
132 'type' => 'string',
133 'default' => 'USD',
134 ],
135 'transaction_id' => [
136 'type' => 'string',
137 'default' => '',
138 ],
139 'customer_id' => [
140 'type' => 'string',
141 'default' => '',
142 ],
143 'stripe_account_id' => [
144 'type' => 'string',
145 'default' => '',
146 ],
147 'gateway' => [
148 'type' => 'string',
149 'default' => 'stripe',
150 ],
151 'payment_status' => [
152 'type' => 'string',
153 'default' => 'pending',
154 ],
155 'payment_mode' => [
156 'type' => 'string',
157 'default' => 'test',
158 ],
159 'donor_name' => [
160 'type' => 'string',
161 'default' => '',
162 ],
163 'donor_email' => [
164 'type' => 'string',
165 'default' => '',
166 ],
167 'donor_phone' => [
168 'type' => 'string',
169 'default' => '',
170 ],
171 'is_anonymous' => [
172 'type' => 'boolean',
173 'default' => false,
174 ],
175 'donation_type' => [
176 'type' => 'string',
177 'default' => 'one-time',
178 ],
179 'subscription_id' => [
180 'type' => 'string',
181 'default' => '',
182 ],
183 'subscription_status' => [
184 'type' => 'string',
185 'default' => '',
186 ],
187 'parent_subscription_id' => [
188 'type' => 'number',
189 'default' => 0,
190 ],
191 'donor_comment' => [
192 'type' => 'string',
193 'default' => '',
194 ],
195 'donor_comment_status' => [
196 'type' => 'string',
197 'default' => 'approved',
198 ],
199 'receipt_sent' => [
200 'type' => 'boolean',
201 'default' => false,
202 ],
203 'receipt_pdf_url' => [
204 'type' => 'string',
205 'default' => '',
206 ],
207 'donation_data' => [
208 'type' => 'array',
209 'default' => [],
210 ],
211 'log' => [
212 'type' => 'array',
213 'default' => [],
214 ],
215 'ip_address' => [
216 'type' => 'string',
217 'default' => '',
218 ],
219 'user_agent' => [
220 'type' => 'string',
221 'default' => '',
222 ],
223 'referer_url' => [
224 'type' => 'string',
225 'default' => '',
226 ],
227 'import_source_id' => [
228 'type' => 'number',
229 'default' => 0,
230 ],
231 'import_source' => [
232 'type' => 'string',
233 'default' => '',
234 ],
235 'import_provenance' => [
236 'type' => 'string',
237 'default' => '',
238 ],
239 'created_at' => [
240 'type' => 'datetime',
241 ],
242 'updated_at' => [
243 'type' => 'datetime',
244 ],
245 ];
246 }
247
248 /**
249 * {@inheritDoc}
250 */
251 public function get_columns_definition() {
252 return [
253 'id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY',
254 'campaign_id BIGINT(20) UNSIGNED NOT NULL',
255 'donor_id BIGINT(20) UNSIGNED NULL',
256 'form_id BIGINT(20) UNSIGNED NULL',
257 'amount DECIMAL(26,8) NOT NULL',
258 'fees_covered DECIMAL(26,8) NOT NULL DEFAULT 0',
259 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0',
260 'currency VARCHAR(10) NOT NULL',
261 'transaction_id VARCHAR(255) NOT NULL',
262 'customer_id VARCHAR(50) NOT NULL',
263 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\'',
264 'gateway VARCHAR(20) NOT NULL',
265 'payment_status VARCHAR(50) NOT NULL',
266 'payment_mode VARCHAR(20) NOT NULL',
267 'donor_name VARCHAR(255) NOT NULL',
268 'donor_email VARCHAR(255) NOT NULL',
269 'donor_phone VARCHAR(50) NOT NULL',
270 'is_anonymous TINYINT(1) NOT NULL DEFAULT 0',
271 'donation_type VARCHAR(30) NOT NULL',
272 'subscription_id VARCHAR(255) NOT NULL',
273 'subscription_status VARCHAR(30) NOT NULL',
274 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
275 'donor_comment TEXT',
276 'donor_comment_status VARCHAR(20) NOT NULL DEFAULT \'approved\'',
277 'receipt_sent TINYINT(1) NOT NULL DEFAULT 0',
278 'receipt_pdf_url VARCHAR(255) NOT NULL',
279 'donation_data LONGTEXT',
280 'log LONGTEXT',
281 'ip_address VARCHAR(45) NOT NULL',
282 'user_agent TEXT',
283 'referer_url TEXT',
284 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
285 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'',
286 'import_provenance VARCHAR(64) NOT NULL DEFAULT \'\'',
287 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
288 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
289 'INDEX idx_campaign (campaign_id)',
290 'INDEX idx_donor (donor_id)',
291 'INDEX idx_status (payment_status)',
292 'INDEX idx_email (donor_email)',
293 'INDEX idx_created (created_at)',
294 'INDEX idx_form (form_id)',
295 'INDEX idx_subscription (subscription_id)',
296 'INDEX idx_subscription_status (subscription_status)',
297 'INDEX idx_parent_subscription (parent_subscription_id)',
298 'INDEX idx_import_source (import_source_id, import_source)',
299 'INDEX idx_import_provenance (import_source, import_provenance)',
300 'INDEX idx_stripe_account (stripe_account_id)',
301 ];
302 }
303
304 /**
305 * New columns added across versions.
306 *
307 * Version 2 added subscription support; version 4 added the
308 * source-agnostic pair `import_source_id` + `import_source` used by
309 * the migration tool for duplicate detection and rollback; version 5
310 * added `stripe_account_id` so donations record which connected Stripe
311 * account processed them (multiple Stripe accounts support); version 6
312 * added `import_provenance` — an indexed `(donation_post_id, source_campaign_id)`
313 * key the Charitable importer dedupes on with a single indexed lookup per
314 * row, instead of scanning + JSON-decoding every prior imported row per batch;
315 * version 7 added `donor_comment_status`, defaulting to `approved` so
316 * comments that predate moderation stay visible.
317 *
318 * Version 7 rather than 6: `import_provenance` had already taken 6 on dev
319 * while this branch was open, and the upgrade only runs when the number
320 * increases (Database\Base::set_db_upgradable()). Leaving both columns on 6
321 * would mean any site already upgraded to 6 never receives
322 * `donor_comment_status`, while get_schema() still declares it and
323 * prepare_data() names every declared column in the INSERT — so every
324 * donation would fail with "Unknown column 'donor_comment_status'".
325 *
326 * No index accompanies `donor_comment_status`: it is `approved` on virtually
327 * every row, so a `(campaign_id, donor_comment_status)` index measured ~3%
328 * better than the existing `idx_campaign` on a 200k-row table and still
329 * filesorted, while adding write cost to the plugin's hottest table. Its one
330 * reader (Campaign_Stats::get_donor_comments()) is also behind a 5-minute
331 * transient. Revisit only if that query shows up in real profiling.
332 *
333 * {@inheritDoc}
334 *
335 * @since 1.0.0
336 */
337 public function get_new_columns_definition() {
338 return [
339 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type',
340 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id',
341 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status',
342 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url',
343 'import_source VARCHAR(20) NOT NULL DEFAULT \'\' AFTER import_source_id',
344 'import_provenance VARCHAR(64) NOT NULL DEFAULT \'\' AFTER import_source',
345 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\' AFTER customer_id',
346 'donor_comment_status VARCHAR(20) NOT NULL DEFAULT \'approved\' AFTER donor_comment',
347 'INDEX idx_subscription (subscription_id)',
348 'INDEX idx_subscription_status (subscription_status)',
349 'INDEX idx_parent_subscription (parent_subscription_id)',
350 'INDEX idx_import_source (import_source_id, import_source)',
351 'INDEX idx_import_provenance (import_source, import_provenance)',
352 'INDEX idx_stripe_account (stripe_account_id)',
353 ];
354 }
355
356 /**
357 * One-time data migrations for the donations table.
358 *
359 * Each backfill is gated on the version being upgraded *into* (via
360 * $this->prev_version) so it runs exactly once, on the upgrade that adds the
361 * column, and is skipped on fresh installs (which create the column already
362 * populated / empty as appropriate) and on later upgrades.
363 *
364 * @return void
365 * @since 1.3.0
366 */
367 public function run_data_migrations() {
368 // A failed CREATE/ALTER earlier in this upgrade already cleared the flag;
369 // the column may not exist, so don't run an UPDATE against it.
370 if ( ! $this->db_upgradable ) {
371 return;
372 }
373
374 if ( $this->prev_version < 5 ) {
375 $this->backfill_stripe_account_id();
376 }
377
378 if ( $this->prev_version < 6 ) {
379 $this->backfill_import_provenance();
380 }
381 }
382
383 /**
384 * Backfill `stripe_account_id` on the upgrade into v5.
385 *
386 * Before multi-account there could only be a single connected Stripe account,
387 * so every pre-v5 Stripe donation belongs to the current (single) default
388 * account. Backfill it so refunds and subscription lifecycle actions keep
389 * routing to the originating account after a second account is connected and
390 * the default is switched. Idempotent (touches only empty rows).
391 *
392 * @return void
393 * @since 1.3.0
394 */
395 private function backfill_stripe_account_id() {
396 if ( ! class_exists( '\SureDonation\Inc\Payments\Stripe\Stripe_Helper' ) ) {
397 return;
398 }
399
400 // Runs during the v5 DB upgrade — before any second account can be
401 // connected via the UI — so the default is still the single legacy account.
402 $account_id = \SureDonation\Inc\Payments\Stripe\Stripe_Helper::get_default_account_id();
403 if ( ! is_string( $account_id ) || '' === $account_id ) {
404 return;
405 }
406
407 global $wpdb;
408 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill of a newly added column; not cacheable.
409 $result = $wpdb->query(
410 $wpdb->prepare(
411 'UPDATE %i SET stripe_account_id = %s WHERE gateway = %s AND ( stripe_account_id = %s OR stripe_account_id IS NULL )',
412 $this->get_tablename(),
413 $account_id,
414 'stripe',
415 ''
416 )
417 );
418
419 // A transient failure (e.g. lock wait timeout on a busy table) must not
420 // persist the new version: `prev_version >= 5` would then skip this
421 // one-shot backfill forever. Leaving the version unwritten makes the
422 // idempotent sequence retry on the next request.
423 if ( false === $result ) {
424 $this->db_upgradable = false;
425 }
426 }
427
428 /**
429 * Backfill `import_provenance` on the upgrade into v6.
430 *
431 * The Charitable importer moved its dedupe key out of a per-batch scan of
432 * `donation_data` and onto this indexed column. Rows imported before v6 have
433 * an empty key, so a re-import after upgrade would fail to match them and
434 * insert duplicates. Reconstruct the key from the stored
435 * `donation_data.charitable` block — the same `(donation_post_id,
436 * source_campaign_id | campaign label)` rule the importer keys on — for every
437 * pre-v6 one-time Charitable row. Chunked so a large migrated table does not
438 * exhaust memory during the upgrade; idempotent (touches only empty keys).
439 *
440 * @return void
441 * @since 1.5.1
442 */
443 private function backfill_import_provenance() {
444 global $wpdb;
445 $table = $this->get_tablename();
446
447 do {
448 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time chunked backfill of a newly added column; not cacheable.
449 $rows = $wpdb->get_results(
450 $wpdb->prepare(
451 'SELECT id, donation_data FROM %i WHERE import_source = %s AND donation_type != %s AND import_provenance = %s LIMIT 500',
452 $table,
453 'charitable',
454 'recurring',
455 ''
456 ),
457 ARRAY_A
458 );
459
460 if ( empty( $rows ) || ! is_array( $rows ) ) {
461 break;
462 }
463
464 $fetched = count( $rows );
465
466 foreach ( $rows as $row ) {
467 $data = json_decode( (string) ( $row['donation_data'] ?? '' ), true );
468 $c = is_array( $data ) && isset( $data['charitable'] ) && is_array( $data['charitable'] ) ? $data['charitable'] : [];
469 $post = isset( $c['donation_post_id'] ) ? absint( $c['donation_post_id'] ) : 0;
470
471 // A row with no resolvable donation post can never be dedupe-matched
472 // or rolled back; leave its key empty (it is already un-reversible)
473 // rather than fabricate a colliding "0:…" key.
474 if ( $post <= 0 ) {
475 $key = '';
476 } else {
477 $campaign = isset( $c['source_campaign_id'] ) ? absint( $c['source_campaign_id'] ) : 0;
478 // DB-path rows carry `campaign_name`; CSV-path rows carry
479 // `campaign_title`. Either serves as the blank-id fallback label.
480 $label = '';
481 if ( isset( $c['campaign_title'] ) && is_scalar( $c['campaign_title'] ) ) {
482 $label = (string) $c['campaign_title'];
483 } elseif ( isset( $c['campaign_name'] ) && is_scalar( $c['campaign_name'] ) ) {
484 $label = (string) $c['campaign_name'];
485 }
486 $key = self::build_provenance_key( $post, $campaign, $label );
487 }
488
489 if ( '' === $key ) {
490 // Nothing to store, but stamp a sentinel so the WHERE clause
491 // stops selecting this row and the loop terminates.
492 $key = '-';
493 }
494
495 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill update; not cacheable.
496 $wpdb->update( $table, [ 'import_provenance' => $key ], [ 'id' => absint( $row['id'] ) ] );
497 }
498 } while ( 500 === $fetched );
499 }
500
501 /**
502 * Build the indexed dedupe key for a Charitable donation row.
503 *
504 * `"<donation_post_id>:<token>"`, where the token is the numeric campaign id
505 * when present, otherwise a short hash of the campaign label (so the
506 * per-campaign rows of a multi-campaign donation whose export left the
507 * Campaign ID cell blank stay distinct instead of collapsing to "<post>:0"),
508 * otherwise "0". Static so both the importer (Provenance_Dedupe) and the v6
509 * backfill derive identical keys.
510 *
511 * @param int $donation_post_id Charitable donation post ID.
512 * @param int $source_campaign_id Charitable campaign ID (0 when absent).
513 * @param string $campaign_label Campaign title/name fallback (optional).
514 * @return string
515 * @since 1.5.1
516 */
517 public static function build_provenance_key( $donation_post_id, $source_campaign_id, $campaign_label = '' ) {
518 $post = absint( $donation_post_id );
519 $cid = absint( $source_campaign_id );
520 $label = trim( (string) $campaign_label );
521
522 if ( $cid > 0 ) {
523 $token = (string) $cid;
524 } elseif ( '' !== $label ) {
525 $token = 't:' . substr( md5( strtolower( $label ) ), 0, 12 );
526 } else {
527 $token = '0';
528 }
529
530 return $post . ':' . $token;
531 }
532
533 /**
534 * Add a new donation record.
535 *
536 * @param array<mixed> $data Donation data to insert.
537 * @return int|false The donation ID on success, false on error.
538 * @since 0.0.1
539 */
540 public static function add( $data ) {
541 // Use isset check — empty() would reject campaign_id=0 which is valid for standalone forms.
542 if ( ! isset( $data['campaign_id'] ) ) {
543 return false;
544 }
545
546 $instance = self::get_instance();
547
548 // Set created_at if not provided (use GMT for consistency with TIMESTAMP column default).
549 if ( ! isset( $data['created_at'] ) ) {
550 $data['created_at'] = current_time( 'mysql', true );
551 }
552
553 $result = $instance->use_insert( $data );
554
555 if ( $result ) {
556 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $data['campaign_id'] ) ) );
557
558 // Notify integration hooks (e.g. OttoKit) about the new donation.
559 // Imported rows carry an import_source and are skipped: migrating
560 // historical donations must not replay automations.
561 if ( empty( $data['import_source'] ) ) {
562 $donation_id = absint( $result );
563 $donation = self::get( $donation_id );
564 $donation = is_array( $donation ) ? $donation : [];
565
566 // Curated payload (internal/gateway-only columns omitted; donor
567 // identity included, see the note in get_integration_payload())
568 // shared by every hook below.
569 $payload = self::get_integration_payload( $donation );
570
571 /**
572 * Fires when a new donation record is created.
573 *
574 * @param int $donation_id Newly created donation ID.
575 * @param array<mixed> $donation Curated donation payload.
576 * @since 1.1.0
577 */
578 do_action( 'suredonation_donation_created', $donation_id, $payload );
579
580 /**
581 * Fires when a new donation record is created.
582 *
583 * Mirrors `suredonation_donation_created`; the OttoKit (formerly
584 * SureTriggers) "New Donation" trigger listens on this hook name.
585 *
586 * @param int $donation_id Newly created donation ID.
587 * @param array<mixed> $donation Curated donation payload.
588 * @since 1.2.0
589 */
590 do_action( 'suredonation_new_donation', $donation_id, $payload );
591
592 // Some donations are created already-completed rather than
593 // transitioning through update() — recurring renewals and
594 // admin-recorded paid donations. Fire the completion event here
595 // too so integration hooks still see them.
596 if ( 'completed' === ( $data['payment_status'] ?? '' ) ) {
597 /**
598 * Fires when a donation payment is completed.
599 *
600 * @param int $donation_id Donation ID.
601 * @param array<mixed> $donation Curated donation payload after insertion.
602 * @since 1.2.0
603 */
604 do_action( 'suredonation_donation_completed', $donation_id, $payload );
605 }
606 }
607 }
608
609 return $result;
610 }
611
612 /**
613 * Update a donation record.
614 *
615 * @param int $donation_id Donation ID to update.
616 * @param array<string,mixed> $data Data to update.
617 * @return int|false Number of rows updated or false on error.
618 * @since 0.0.1
619 */
620 public static function update( $donation_id, $data = [] ) {
621 if ( empty( $donation_id ) ) {
622 return false;
623 }
624
625 // Capture the current status and refunded amount before the write so
626 // integration hooks (e.g. OttoKit) can react to the transition and to
627 // refund events, not just the resulting values.
628 $old_status = '';
629 $old_refunded = 0.0;
630 if ( isset( $data['payment_status'] ) || isset( $data['refunded_amount'] ) ) {
631 $existing = self::get( absint( $donation_id ) );
632 $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : '';
633 $old_refunded = is_array( $existing ) ? Helper::get_float_value( $existing['refunded_amount'] ?? 0 ) : 0.0;
634 }
635
636 // Set updated_at.
637 $data['updated_at'] = current_time( 'mysql' );
638
639 $updated = self::get_instance()->use_update( $data, [ 'id' => absint( $donation_id ) ] );
640
641 // Status/amount changes (e.g. a webhook completing a pending donation)
642 // affect the cached stats and donor lists.
643 if ( $updated ) {
644 $donation = self::get( absint( $donation_id ) );
645 $donation = is_array( $donation ) ? $donation : [];
646 if ( ! empty( $donation['campaign_id'] ) ) {
647 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) );
648 }
649
650 // Curated payload (internal/gateway-only columns omitted; donor
651 // identity included, see the note in get_integration_payload())
652 // shared by every hook below.
653 $payload = self::get_integration_payload( $donation );
654
655 if ( isset( $data['payment_status'] ) ) {
656 $new_status = Helper::get_string_value( $data['payment_status'] );
657
658 if ( $new_status !== $old_status ) {
659 /**
660 * Fires when a donation's payment status changes.
661 *
662 * @param int $donation_id Donation ID.
663 * @param string $new_status New payment status.
664 * @param string $old_status Previous payment status (empty string if unknown).
665 * @param array<mixed> $donation Curated donation payload after the update.
666 * @since 1.1.0
667 */
668 do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $payload );
669
670 // Fire the completion event for any genuine transition into
671 // 'completed' — including admin review states (suspicious,
672 // cancelled) — but never for refund reversals that restore
673 // the 'completed' status (refunded/partially_refunded ->
674 // completed), which would replay the completion automation.
675 if ( 'completed' === $new_status && ! in_array( $old_status, [ 'completed', 'refunded', 'partially_refunded' ], true ) ) {
676 /**
677 * Fires when a donation payment is completed.
678 *
679 * @param int $donation_id Donation ID.
680 * @param array<mixed> $donation Curated donation payload after the update.
681 * @since 1.2.0
682 */
683 do_action( 'suredonation_donation_completed', absint( $donation_id ), $payload );
684 }
685 }
686 }
687
688 // A rise in refunded_amount means a refund was processed. Keying off
689 // the amount (not the status string) catches repeat partial refunds
690 // that leave the status as partially_refunded, and excludes refund
691 // reversals where the amount drops.
692 if ( isset( $data['refunded_amount'] ) ) {
693 $new_refunded = Helper::get_float_value( $data['refunded_amount'] );
694
695 if ( $new_refunded - $old_refunded > 0.0001 ) {
696 /**
697 * Fires when a donation is refunded, fully or partially.
698 *
699 * @param int $donation_id Donation ID.
700 * @param float $refund_amount Amount refunded in this event.
701 * @param float $total_refunded Cumulative amount refunded to date.
702 * @param array<mixed> $donation Curated donation payload after the update.
703 * @since 1.2.0
704 */
705 do_action( 'suredonation_donation_refunded', absint( $donation_id ), $new_refunded - $old_refunded, $new_refunded, $payload );
706 }
707 }
708 }
709
710 return $updated;
711 }
712
713 /**
714 * Build a curated donation payload for integration hooks.
715 *
716 * Trims the raw database row to the fields advertised in the OttoKit embed
717 * `sample_response`, omitting internal and gateway-only columns that must not
718 * leave the site (ip_address, user_agent, referer_url, the admin `log`, the
719 * gateway `customer_id`, and the full `donation_data` submission). Monetary
720 * values are cast to float to match the sample the automation builder maps
721 * against (the raw column is a DECIMAL string). Shared by every `do_action`
722 * in add()/update() so no listener — OttoKit or otherwise — receives the raw
723 * row.
724 *
725 * Anonymous donations carry their real donor identity here. The anonymous
726 * checkbox is a display-only flag — the data is stored and processed as
727 * usual, and only the public donor wall / recent donations / top donors mask
728 * it. Automations that need to treat anonymous donors differently branch on
729 * the `is_anonymous` field in this payload; blanking the identity instead
730 * would silently break receipting and CRM sync for those donations.
731 *
732 * @param array<string,mixed> $donation Raw donation record from self::get().
733 * @return array<string,mixed> Curated, integration-safe payload.
734 * @since 1.2.0
735 */
736 public static function get_integration_payload( $donation ) {
737 if ( ! is_array( $donation ) ) {
738 return [];
739 }
740
741 $is_anonymous = ! empty( $donation['is_anonymous'] );
742
743 $payload = [
744 'id' => isset( $donation['id'] ) ? absint( Helper::get_string_value( $donation['id'] ) ) : 0,
745 'campaign_id' => isset( $donation['campaign_id'] ) ? absint( Helper::get_string_value( $donation['campaign_id'] ) ) : 0,
746 'form_id' => isset( $donation['form_id'] ) ? absint( Helper::get_string_value( $donation['form_id'] ) ) : 0,
747 'donor_id' => isset( $donation['donor_id'] ) ? absint( Helper::get_string_value( $donation['donor_id'] ) ) : 0,
748 'donor_name' => Helper::get_string_value( $donation['donor_name'] ?? '' ),
749 'donor_email' => Helper::get_string_value( $donation['donor_email'] ?? '' ),
750 'donor_phone' => Helper::get_string_value( $donation['donor_phone'] ?? '' ),
751 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
752 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
753 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
754 'currency' => Helper::get_string_value( $donation['currency'] ?? '' ),
755 'gateway' => Helper::get_string_value( $donation['gateway'] ?? '' ),
756 'payment_status' => Helper::get_string_value( $donation['payment_status'] ?? '' ),
757 'payment_mode' => Helper::get_string_value( $donation['payment_mode'] ?? '' ),
758 'donation_type' => Helper::get_string_value( $donation['donation_type'] ?? '' ),
759 'transaction_id' => Helper::get_string_value( $donation['transaction_id'] ?? '' ),
760 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ),
761 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ),
762 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ),
763 'donor_comment_status' => Helper::get_string_value( $donation['donor_comment_status'] ?? '' ),
764 'is_anonymous' => $is_anonymous,
765 'created_at' => Helper::get_string_value( $donation['created_at'] ?? '' ),
766 'updated_at' => Helper::get_string_value( $donation['updated_at'] ?? '' ),
767 ];
768
769 /**
770 * Filter the curated donation payload passed to every integration hook.
771 *
772 * The payload carries the donor's real identity even for anonymous
773 * donations, because the anonymous checkbox only masks public donor
774 * lists — automations still need a usable record, and they can branch on
775 * the `is_anonymous` field. A site with a stricter policy (for example an
776 * automation that posts donor names somewhere public) can use this filter
777 * to blank or drop fields before they reach OttoKit or any third-party
778 * listener.
779 *
780 * @param array<string,mixed> $payload Curated payload.
781 * @param array<string,mixed> $donation Raw donation record.
782 * @since 1.4.0
783 */
784 return apply_filters( 'suredonation_integration_payload', $payload, $donation );
785 }
786
787 /**
788 * Get a single donation by ID.
789 *
790 * @param int $donation_id Donation ID.
791 * @return array<mixed>|null Donation data or null if not found.
792 * @since 0.0.1
793 */
794 public static function get( $donation_id ) {
795 if ( empty( $donation_id ) ) {
796 return null;
797 }
798
799 $instance = self::get_instance();
800 global $wpdb;
801
802 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
803 $result = $wpdb->get_row(
804 $wpdb->prepare(
805 'SELECT * FROM %i WHERE id = %d',
806 $instance->get_tablename(),
807 absint( $donation_id )
808 ),
809 ARRAY_A
810 );
811
812 if ( ! $result ) {
813 return null;
814 }
815
816 return $instance->decode_by_datatype( $result );
817 }
818
819 /**
820 * Get all donations with pagination.
821 *
822 * @param int $limit Number of records to return.
823 * @param int $offset Offset for pagination.
824 * @param string $orderby Column to order by.
825 * @param string $order Order direction (ASC or DESC).
826 * @return array<mixed> Array of donations.
827 * @since 0.0.1
828 */
829 public static function get_all( $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
830 $instance = self::get_instance();
831 global $wpdb;
832 $table = $instance->get_tablename();
833
834 // Validate orderby column.
835 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
836 $orderby = 'created_at';
837 }
838
839 // Validate order direction.
840 $order = strtoupper( $order );
841 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
842 $order = 'DESC';
843 }
844
845 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
846 $results = 'ASC' === $order
847 ? $wpdb->get_results(
848 $wpdb->prepare(
849 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
850 $table,
851 $orderby,
852 absint( $offset ),
853 absint( $limit )
854 ),
855 ARRAY_A
856 )
857 : $wpdb->get_results(
858 $wpdb->prepare(
859 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
860 $table,
861 $orderby,
862 absint( $offset ),
863 absint( $limit )
864 ),
865 ARRAY_A
866 );
867 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
868
869 if ( ! $results || ! is_array( $results ) ) {
870 return [];
871 }
872
873 return array_map( [ $instance, 'decode_by_datatype' ], $results );
874 }
875
876 /**
877 * Get donations for admin listing with optional filters.
878 *
879 * @param string $status Payment status filter ('all' for no filter).
880 * @param int $campaign_id Campaign ID filter (0 for no filter).
881 * @param string $search Search term for donor_name, donor_email, or transaction_id.
882 * @param int $limit Number of records to return.
883 * @param int $offset Offset for pagination.
884 * @param string $orderby Column to order by.
885 * @param string $order Order direction (ASC or DESC).
886 * @return array<mixed> Array of donations.
887 * @since 0.0.1
888 */
889 public static function get_admin_list( $status = 'all', $campaign_id = 0, $search = '', $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
890 $instance = self::get_instance();
891 global $wpdb;
892 $table = $instance->get_tablename();
893
894 // Validate orderby column.
895 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
896 $orderby = 'created_at';
897 }
898
899 // Validate order direction.
900 $order = strtoupper( $order );
901 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
902 $order = 'DESC';
903 }
904
905 // Build query based on filters.
906 // Note: Renewal records (donation_type = 'renewal') are intentionally included in the listing.
907 // They are shown alongside parent subscriptions so admins can see all transaction activity.
908 // Renewals are also accessible from the parent donation's subscription detail billing history.
909 // With no status filter, abandoned rows are left out: they are kept as
910 // funnel data (a campaign with 40 starts against 3 completions has
911 // learned something real) but a donor who walked away from the gateway is
912 // not a transaction an admin needs in their default view. Asking for the
913 // status explicitly still returns them, and count_admin_list() mirrors
914 // this or the pagination totals disagree with the rows.
915 $has_status = 'all' !== $status;
916 $has_campaign = $campaign_id > 0;
917 $has_search = ! empty( $search );
918 $is_asc = 'ASC' === $order;
919
920 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
921
922 // All three filters.
923 if ( $has_status && $has_campaign && $has_search ) {
924 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
925 $results = $is_asc
926 ? $wpdb->get_results(
927 $wpdb->prepare(
928 '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',
929 $table,
930 sanitize_text_field( $status ),
931 absint( $campaign_id ),
932 $search_term,
933 $search_term,
934 $search_term,
935 $orderby,
936 absint( $offset ),
937 absint( $limit )
938 ),
939 ARRAY_A
940 )
941 : $wpdb->get_results(
942 $wpdb->prepare(
943 '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',
944 $table,
945 sanitize_text_field( $status ),
946 absint( $campaign_id ),
947 $search_term,
948 $search_term,
949 $search_term,
950 $orderby,
951 absint( $offset ),
952 absint( $limit )
953 ),
954 ARRAY_A
955 );
956 } elseif ( $has_status && $has_campaign ) {
957 $results = $is_asc
958 ? $wpdb->get_results(
959 $wpdb->prepare(
960 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
961 $table,
962 sanitize_text_field( $status ),
963 absint( $campaign_id ),
964 $orderby,
965 absint( $offset ),
966 absint( $limit )
967 ),
968 ARRAY_A
969 )
970 : $wpdb->get_results(
971 $wpdb->prepare(
972 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
973 $table,
974 sanitize_text_field( $status ),
975 absint( $campaign_id ),
976 $orderby,
977 absint( $offset ),
978 absint( $limit )
979 ),
980 ARRAY_A
981 );
982 } elseif ( $has_status && $has_search ) {
983 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
984 $results = $is_asc
985 ? $wpdb->get_results(
986 $wpdb->prepare(
987 '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',
988 $table,
989 sanitize_text_field( $status ),
990 $search_term,
991 $search_term,
992 $search_term,
993 $orderby,
994 absint( $offset ),
995 absint( $limit )
996 ),
997 ARRAY_A
998 )
999 : $wpdb->get_results(
1000 $wpdb->prepare(
1001 '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',
1002 $table,
1003 sanitize_text_field( $status ),
1004 $search_term,
1005 $search_term,
1006 $search_term,
1007 $orderby,
1008 absint( $offset ),
1009 absint( $limit )
1010 ),
1011 ARRAY_A
1012 );
1013 } elseif ( $has_campaign && $has_search ) {
1014 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
1015 $results = $is_asc
1016 ? $wpdb->get_results(
1017 $wpdb->prepare(
1018 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d',
1019 $table,
1020 absint( $campaign_id ),
1021 $search_term,
1022 $search_term,
1023 $search_term,
1024 $orderby,
1025 absint( $offset ),
1026 absint( $limit )
1027 ),
1028 ARRAY_A
1029 )
1030 : $wpdb->get_results(
1031 $wpdb->prepare(
1032 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d',
1033 $table,
1034 absint( $campaign_id ),
1035 $search_term,
1036 $search_term,
1037 $search_term,
1038 $orderby,
1039 absint( $offset ),
1040 absint( $limit )
1041 ),
1042 ARRAY_A
1043 );
1044 } elseif ( $has_status ) {
1045 $results = $is_asc
1046 ? $wpdb->get_results(
1047 $wpdb->prepare(
1048 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
1049 $table,
1050 sanitize_text_field( $status ),
1051 $orderby,
1052 absint( $offset ),
1053 absint( $limit )
1054 ),
1055 ARRAY_A
1056 )
1057 : $wpdb->get_results(
1058 $wpdb->prepare(
1059 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
1060 $table,
1061 sanitize_text_field( $status ),
1062 $orderby,
1063 absint( $offset ),
1064 absint( $limit )
1065 ),
1066 ARRAY_A
1067 );
1068 } elseif ( $has_campaign ) {
1069 $results = $is_asc
1070 ? $wpdb->get_results(
1071 $wpdb->prepare(
1072 'SELECT * FROM %i WHERE campaign_id = %d AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d',
1073 $table,
1074 absint( $campaign_id ),
1075 $orderby,
1076 absint( $offset ),
1077 absint( $limit )
1078 ),
1079 ARRAY_A
1080 )
1081 : $wpdb->get_results(
1082 $wpdb->prepare(
1083 'SELECT * FROM %i WHERE campaign_id = %d AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d',
1084 $table,
1085 absint( $campaign_id ),
1086 $orderby,
1087 absint( $offset ),
1088 absint( $limit )
1089 ),
1090 ARRAY_A
1091 );
1092 } elseif ( $has_search ) {
1093 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
1094 $results = $is_asc
1095 ? $wpdb->get_results(
1096 $wpdb->prepare(
1097 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d',
1098 $table,
1099 $search_term,
1100 $search_term,
1101 $search_term,
1102 $orderby,
1103 absint( $offset ),
1104 absint( $limit )
1105 ),
1106 ARRAY_A
1107 )
1108 : $wpdb->get_results(
1109 $wpdb->prepare(
1110 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d',
1111 $table,
1112 $search_term,
1113 $search_term,
1114 $search_term,
1115 $orderby,
1116 absint( $offset ),
1117 absint( $limit )
1118 ),
1119 ARRAY_A
1120 );
1121 } else {
1122 $results = $is_asc
1123 ? $wpdb->get_results(
1124 $wpdb->prepare(
1125 'SELECT * FROM %i WHERE payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d',
1126 $table,
1127 $orderby,
1128 absint( $offset ),
1129 absint( $limit )
1130 ),
1131 ARRAY_A
1132 )
1133 : $wpdb->get_results(
1134 $wpdb->prepare(
1135 'SELECT * FROM %i WHERE payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d',
1136 $table,
1137 $orderby,
1138 absint( $offset ),
1139 absint( $limit )
1140 ),
1141 ARRAY_A
1142 );
1143 }
1144
1145 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1146
1147 if ( ! $results || ! is_array( $results ) ) {
1148 return [];
1149 }
1150
1151 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1152 }
1153
1154 /**
1155 * Build the WHERE clause + prepare-args for an export query.
1156 *
1157 * Always constrains to one-time donations (subscription_id = '' AND
1158 * parent_subscription_id = 0) so recurring/renewal rows never leak into the
1159 * free export — recurring export is Pro (see the Import & Export spec, #237).
1160 * Optional filters: status, campaign_id, payment_mode, gateway, and a
1161 * created_at date range (after / before).
1162 *
1163 * @param array<string, mixed> $filters Filter map.
1164 * @param array<int, mixed> $args Prepare-args, populated by reference in placeholder order.
1165 * @return string WHERE clause (without the "WHERE" keyword); placeholders only, no interpolated values.
1166 * @since 1.3.0
1167 */
1168 private static function build_export_where( $filters, &$args ) {
1169 $conditions = [ '1=1' ];
1170
1171 /**
1172 * Whether the donations export is restricted to one-time donations.
1173 *
1174 * True by default so recurring/renewal rows never leak into the free
1175 * export; Pro returns false to include subscriptions and renewals.
1176 *
1177 * @param bool $one_time_only Whether to restrict to one-time donations.
1178 */
1179 if ( apply_filters( 'suredonation_export_one_time_only', true ) ) {
1180 $conditions[] = 'subscription_id = %s';
1181 $conditions[] = 'parent_subscription_id = %d';
1182 $args[] = '';
1183 $args[] = 0;
1184 }
1185
1186 $status = sanitize_text_field( Helper::get_string_value( $filters['status'] ?? '' ) );
1187 if ( '' !== $status && 'all' !== $status ) {
1188 $conditions[] = 'payment_status = %s';
1189 $args[] = $status;
1190 }
1191
1192 $campaign_id = absint( Helper::get_string_value( $filters['campaign_id'] ?? 0 ) );
1193 if ( $campaign_id > 0 ) {
1194 $conditions[] = 'campaign_id = %d';
1195 $args[] = $campaign_id;
1196 }
1197
1198 $payment_mode = sanitize_text_field( Helper::get_string_value( $filters['payment_mode'] ?? '' ) );
1199 if ( '' !== $payment_mode ) {
1200 $conditions[] = 'payment_mode = %s';
1201 $args[] = $payment_mode;
1202 }
1203
1204 $gateway = sanitize_text_field( Helper::get_string_value( $filters['gateway'] ?? '' ) );
1205 if ( '' !== $gateway ) {
1206 $conditions[] = 'gateway = %s';
1207 $args[] = $gateway;
1208 }
1209
1210 $after = sanitize_text_field( Helper::get_string_value( $filters['after'] ?? '' ) );
1211 if ( '' !== $after ) {
1212 $conditions[] = 'created_at >= %s';
1213 $args[] = $after;
1214 }
1215
1216 $before = sanitize_text_field( Helper::get_string_value( $filters['before'] ?? '' ) );
1217 if ( '' !== $before ) {
1218 // A date-only `before` (Y-m-d) coerces to 00:00:00, which would
1219 // silently drop donations made later that same day. Normalize to
1220 // end-of-day so the whole end date is inclusive; full datetimes
1221 // are left untouched.
1222 if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $before ) ) {
1223 $before .= ' 23:59:59';
1224 }
1225 $conditions[] = 'created_at <= %s';
1226 $args[] = $before;
1227 }
1228
1229 return implode( ' AND ', $conditions );
1230 }
1231
1232 /**
1233 * Count one-time donations matching the export filters.
1234 *
1235 * @param array<string, mixed> $filters Filter map (see build_export_where()).
1236 * @return int Matching row count.
1237 * @since 1.3.0
1238 */
1239 public static function count_for_export( $filters = [] ) {
1240 $instance = self::get_instance();
1241 global $wpdb;
1242 $table = $instance->get_tablename();
1243
1244 $args = [];
1245 $where = self::build_export_where( $filters, $args );
1246
1247 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export count over live data.
1248 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args.
1249 $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( [ $table ], $args ) ) );
1250 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1251
1252 return is_numeric( $count ) ? (int) $count : 0;
1253 }
1254
1255 /**
1256 * Fetch one-time donations for export, decoded.
1257 *
1258 * @param array<string, mixed> $filters Filter map (see build_export_where()).
1259 * @param int $limit Max rows to return (0 = no limit).
1260 * @param int $offset Offset for pagination.
1261 * @return array<int, array<string, mixed>> Decoded donation rows.
1262 * @since 1.3.0
1263 */
1264 public static function get_for_export( $filters = [], $limit = 0, $offset = 0 ) {
1265 $instance = self::get_instance();
1266 global $wpdb;
1267 $table = $instance->get_tablename();
1268
1269 $args = [];
1270 $where = self::build_export_where( $filters, $args );
1271
1272 $sql = "SELECT * FROM %i WHERE {$where} ORDER BY created_at DESC";
1273 $prepare_args = array_merge( [ $table ], $args );
1274
1275 if ( $limit > 0 ) {
1276 $sql .= ' LIMIT %d, %d';
1277 $prepare_args[] = absint( $offset );
1278 $prepare_args[] = absint( $limit );
1279 }
1280
1281 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export query over live data.
1282 // 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.
1283 $results = $wpdb->get_results( $wpdb->prepare( $sql, $prepare_args ), ARRAY_A );
1284 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1285
1286 if ( ! $results || ! is_array( $results ) ) {
1287 return [];
1288 }
1289
1290 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1291 }
1292
1293 /**
1294 * Get donations by status with pagination.
1295 *
1296 * @param string $status Payment status.
1297 * @param int $limit Number of records to return.
1298 * @param int $offset Offset for pagination.
1299 * @param string $orderby Column to order by.
1300 * @param string $order Order direction (ASC or DESC).
1301 * @return array<mixed> Array of donations.
1302 * @since 0.0.1
1303 */
1304 public static function get_by_status( $status, $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
1305 $instance = self::get_instance();
1306 global $wpdb;
1307 $table = $instance->get_tablename();
1308
1309 // Validate orderby column.
1310 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
1311 $orderby = 'created_at';
1312 }
1313
1314 // Validate order direction.
1315 $order = strtoupper( $order );
1316 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
1317 $order = 'DESC';
1318 }
1319
1320 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
1321 $results = 'ASC' === $order
1322 ? $wpdb->get_results(
1323 $wpdb->prepare(
1324 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
1325 $table,
1326 sanitize_text_field( $status ),
1327 $orderby,
1328 absint( $offset ),
1329 absint( $limit )
1330 ),
1331 ARRAY_A
1332 )
1333 : $wpdb->get_results(
1334 $wpdb->prepare(
1335 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
1336 $table,
1337 sanitize_text_field( $status ),
1338 $orderby,
1339 absint( $offset ),
1340 absint( $limit )
1341 ),
1342 ARRAY_A
1343 );
1344 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1345
1346 if ( ! $results || ! is_array( $results ) ) {
1347 return [];
1348 }
1349
1350 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1351 }
1352
1353 /**
1354 * Get donations by campaign ID with pagination.
1355 *
1356 * @param int $campaign_id Campaign ID.
1357 * @param int $limit Number of records to return.
1358 * @param int $offset Offset for pagination.
1359 * @param string $orderby Column to order by.
1360 * @param string $order Order direction (ASC or DESC).
1361 * @return array<mixed> Array of donations.
1362 * @since 0.0.1
1363 */
1364 public static function get_by_campaign_id( $campaign_id, $limit = 100, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
1365 if ( empty( $campaign_id ) ) {
1366 return [];
1367 }
1368
1369 $instance = self::get_instance();
1370 global $wpdb;
1371 $table = $instance->get_tablename();
1372
1373 // Validate orderby column.
1374 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
1375 $orderby = 'created_at';
1376 }
1377
1378 // Validate order direction.
1379 $order = strtoupper( $order );
1380 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
1381 $order = 'DESC';
1382 }
1383
1384 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
1385 $results = 'ASC' === $order
1386 ? $wpdb->get_results(
1387 $wpdb->prepare(
1388 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
1389 $table,
1390 absint( $campaign_id ),
1391 $orderby,
1392 absint( $offset ),
1393 absint( $limit )
1394 ),
1395 ARRAY_A
1396 )
1397 : $wpdb->get_results(
1398 $wpdb->prepare(
1399 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
1400 $table,
1401 absint( $campaign_id ),
1402 $orderby,
1403 absint( $offset ),
1404 absint( $limit )
1405 ),
1406 ARRAY_A
1407 );
1408 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1409
1410 if ( ! $results || ! is_array( $results ) ) {
1411 return [];
1412 }
1413
1414 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1415 }
1416
1417 /**
1418 * Delete a donation record.
1419 *
1420 * @param int $donation_id Donation ID.
1421 * @return int|false Number of rows deleted or false on error.
1422 * @since 0.0.1
1423 */
1424 public static function delete( $donation_id ) {
1425 if ( empty( $donation_id ) ) {
1426 return false;
1427 }
1428
1429 return self::get_instance()->use_delete( [ 'id' => absint( $donation_id ) ] );
1430 }
1431
1432 /**
1433 * Get donations by donor email.
1434 *
1435 * @param string $email Donor email.
1436 * @param int $limit Max rows to return; 0 (default) returns all rows.
1437 * @param int $offset Row offset, applied only when $limit > 0.
1438 * @return array<mixed> Array of donations.
1439 * @since 0.0.1
1440 */
1441 public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) {
1442 if ( empty( $email ) ) {
1443 return [];
1444 }
1445
1446 $instance = self::get_instance();
1447 global $wpdb;
1448
1449 $limit = max( 0, (int) $limit );
1450 $offset = max( 0, (int) $offset );
1451
1452 if ( $limit > 0 ) {
1453 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1454 $results = $wpdb->get_results(
1455 $wpdb->prepare(
1456 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d',
1457 $instance->get_tablename(),
1458 sanitize_email( $email ),
1459 $limit,
1460 $offset
1461 ),
1462 ARRAY_A
1463 );
1464 } else {
1465 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1466 $results = $wpdb->get_results(
1467 $wpdb->prepare(
1468 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC',
1469 $instance->get_tablename(),
1470 sanitize_email( $email )
1471 ),
1472 ARRAY_A
1473 );
1474 }
1475
1476 if ( ! $results || ! is_array( $results ) ) {
1477 return [];
1478 }
1479
1480 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1481 }
1482
1483 /**
1484 * Get donation by transaction ID.
1485 *
1486 * @param string $transaction_id Transaction ID.
1487 * @return array<string, mixed>|null Donation data or null if not found.
1488 * @since 0.0.1
1489 */
1490 public static function get_by_transaction_id( $transaction_id ) {
1491 if ( empty( $transaction_id ) ) {
1492 return null;
1493 }
1494
1495 $instance = self::get_instance();
1496 global $wpdb;
1497
1498 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1499 $result = $wpdb->get_row(
1500 $wpdb->prepare(
1501 'SELECT * FROM %i WHERE transaction_id = %s LIMIT 1',
1502 $instance->get_tablename(),
1503 sanitize_text_field( $transaction_id )
1504 ),
1505 ARRAY_A
1506 );
1507
1508 if ( ! $result ) {
1509 return null;
1510 }
1511
1512 return $instance->decode_by_datatype( $result );
1513 }
1514
1515 /**
1516 * Get donation by gateway subscription ID.
1517 *
1518 * Recurring handling lives in Pro, but the table (and its
1519 * `idx_subscription` index) belongs here, so free-side code that only needs
1520 * to resolve a row — such as the PayPal webhook listener recording why a
1521 * delivery was rejected — can look one up without depending on Pro.
1522 *
1523 * Renewals carry the same `subscription_id` as the subscription they belong
1524 * to, so the column is deliberately not unique. The parent row (the one with
1525 * no `parent_subscription_id`) is preferred and the oldest id breaks any
1526 * remaining tie, so the result does not depend on the query plan.
1527 *
1528 * @param string $subscription_id Gateway subscription ID.
1529 * @return array<string, mixed>|null Donation data or null if not found.
1530 * @since 1.4.0
1531 */
1532 public static function get_by_subscription_id( $subscription_id ) {
1533 if ( empty( $subscription_id ) ) {
1534 return null;
1535 }
1536
1537 $instance = self::get_instance();
1538 global $wpdb;
1539
1540 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1541 $result = $wpdb->get_row(
1542 $wpdb->prepare(
1543 'SELECT * FROM %i WHERE subscription_id = %s ORDER BY parent_subscription_id ASC, id ASC LIMIT 1',
1544 $instance->get_tablename(),
1545 sanitize_text_field( $subscription_id )
1546 ),
1547 ARRAY_A
1548 );
1549
1550 if ( ! $result ) {
1551 return null;
1552 }
1553
1554 return $instance->decode_by_datatype( $result );
1555 }
1556
1557 /**
1558 * Get total donations count (no filters).
1559 *
1560 * @return int Total count.
1561 * @since 0.0.1
1562 */
1563 public static function count_all() {
1564 $instance = self::get_instance();
1565 global $wpdb;
1566
1567 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1568 $count = $wpdb->get_var(
1569 $wpdb->prepare(
1570 'SELECT COUNT(*) FROM %i',
1571 $instance->get_tablename()
1572 )
1573 );
1574
1575 return is_numeric( $count ) ? (int) $count : 0;
1576 }
1577
1578 /**
1579 * Get total donations count by payment status.
1580 *
1581 * @param string $status Payment status.
1582 * @return int Total count.
1583 * @since 0.0.1
1584 */
1585 public static function count_by_status( $status ) {
1586 $instance = self::get_instance();
1587 global $wpdb;
1588
1589 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1590 $count = $wpdb->get_var(
1591 $wpdb->prepare(
1592 'SELECT COUNT(*) FROM %i WHERE payment_status = %s',
1593 $instance->get_tablename(),
1594 sanitize_text_field( $status )
1595 )
1596 );
1597
1598 return is_numeric( $count ) ? (int) $count : 0;
1599 }
1600
1601 /**
1602 * Get the count of completed, live-mode donations.
1603 *
1604 * Used to gate the review admin notice: a completed live donation is the
1605 * signal that the site has taken a genuine (non-test) donation.
1606 *
1607 * @param string $gateway Optional gateway to scope the count to, e.g. 'paypal'.
1608 * Empty counts every gateway.
1609 * @return int Count of completed live donations.
1610 * @since 1.2.0
1611 * @since 1.5.1 Optionally scoped to one gateway.
1612 */
1613 public static function count_live_completed( $gateway = '' ) {
1614 $instance = self::get_instance();
1615 global $wpdb;
1616
1617 if ( '' !== $gateway ) {
1618 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1619 $count = $wpdb->get_var(
1620 $wpdb->prepare(
1621 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s AND gateway = %s',
1622 $instance->get_tablename(),
1623 'completed',
1624 'live',
1625 $gateway
1626 )
1627 );
1628
1629 return is_numeric( $count ) ? (int) $count : 0;
1630 }
1631
1632 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1633 $count = $wpdb->get_var(
1634 $wpdb->prepare(
1635 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s',
1636 $instance->get_tablename(),
1637 'completed',
1638 'live'
1639 )
1640 );
1641
1642 return is_numeric( $count ) ? (int) $count : 0;
1643 }
1644
1645 /**
1646 * Get total donations count by campaign.
1647 *
1648 * @param int $campaign_id Campaign ID.
1649 * @return int Total count.
1650 * @since 0.0.1
1651 */
1652 public static function count_by_campaign( $campaign_id ) {
1653 $instance = self::get_instance();
1654 global $wpdb;
1655
1656 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1657 $count = $wpdb->get_var(
1658 $wpdb->prepare(
1659 'SELECT COUNT(*) FROM %i WHERE campaign_id = %d',
1660 $instance->get_tablename(),
1661 absint( $campaign_id )
1662 )
1663 );
1664
1665 return is_numeric( $count ) ? (int) $count : 0;
1666 }
1667
1668 /**
1669 * Get total donations count by status and campaign.
1670 *
1671 * @param string $status Payment status ('all' for no filter).
1672 * @param int $campaign_id Optional campaign ID (0 for no filter).
1673 * @return int Total count.
1674 * @since 0.0.1
1675 */
1676 public static function get_total_donations_by_status( $status = 'all', $campaign_id = 0 ) {
1677 $instance = self::get_instance();
1678 global $wpdb;
1679
1680 // Both filters.
1681 if ( 'all' !== $status && $campaign_id > 0 ) {
1682 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1683 $count = $wpdb->get_var(
1684 $wpdb->prepare(
1685 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND campaign_id = %d',
1686 $instance->get_tablename(),
1687 sanitize_text_field( $status ),
1688 absint( $campaign_id )
1689 )
1690 );
1691 return is_numeric( $count ) ? (int) $count : 0;
1692 }
1693
1694 // Status filter only.
1695 if ( 'all' !== $status ) {
1696 return self::count_by_status( $status );
1697 }
1698
1699 // Campaign filter only.
1700 if ( $campaign_id > 0 ) {
1701 return self::count_by_campaign( $campaign_id );
1702 }
1703
1704 // No filters.
1705 return self::count_all();
1706 }
1707
1708 /**
1709 * Build the currency / payment-mode scope for a reporting query.
1710 *
1711 * Amounts in different currencies cannot be summed into one figure, and test
1712 * donations must not be counted alongside live ones. Both filters are opt-in
1713 * so existing callers keep their behaviour; the abilities always pass them.
1714 *
1715 * @param string $currency Currency code ('' for no filter).
1716 * @param string $payment_mode 'test' or 'live' ('' for no filter).
1717 * @param array<mixed> $args Prepare args, appended to by reference.
1718 * @return string SQL fragment beginning with " AND ", or '' when unscoped.
1719 * @since 1.5.0
1720 */
1721 private static function scope_fragment( $currency, $payment_mode, array &$args ) {
1722 $extra = '';
1723
1724 $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : '';
1725 if ( '' !== $currency ) {
1726 $extra .= ' AND currency = %s';
1727 $args[] = $currency;
1728 }
1729
1730 $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : '';
1731 if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1732 $extra .= ' AND payment_mode = %s';
1733 $args[] = $payment_mode;
1734 }
1735
1736 return $extra;
1737 }
1738 /**
1739 * Get global dashboard statistics.
1740 *
1741 * @param string $currency Currency code to scope to ('' for no filter).
1742 * @param string $payment_mode 'test' or 'live' ('' for no filter).
1743 * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics.
1744 * @since 0.0.1
1745 */
1746 public static function get_dashboard_stats( $currency = '', $payment_mode = '' ) {
1747 $instance = self::get_instance();
1748 global $wpdb;
1749
1750 $args = [ $instance->get_tablename() ];
1751 $extra = self::scope_fragment( $currency, $payment_mode, $args );
1752
1753 $sql = "SELECT
1754 COUNT(*) as total_donations,
1755 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1756 COUNT(DISTINCT donor_email) as unique_donors,
1757 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1758 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1759 FROM %i
1760 WHERE payment_status IN ('completed', 'partially_refunded')
1761 {$extra}";
1762
1763 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args.
1764 $stats = $wpdb->get_row( $wpdb->prepare( $sql, $args ), ARRAY_A );
1765
1766 return $stats ? $stats : [
1767 'total_donations' => 0,
1768 'total_raised' => 0,
1769 'unique_donors' => 0,
1770 'average_donation' => 0,
1771 'largest_donation' => 0,
1772 ];
1773 }
1774
1775 /**
1776 * Get recent donations globally (all campaigns).
1777 *
1778 * @param int $limit Number of donations to retrieve.
1779 * @param string $currency Currency code to scope to ('' for no filter).
1780 * @param string $payment_mode 'test' or 'live' ('' for no filter).
1781 * @return array<int, array<string, mixed>> Array of recent donations.
1782 * @since 0.0.1
1783 */
1784 public static function get_recent_donations_global( $limit = 5, $currency = '', $payment_mode = '' ) {
1785 $instance = self::get_instance();
1786 global $wpdb;
1787
1788 $args = [ $instance->get_tablename() ];
1789 $extra = self::scope_fragment( $currency, $payment_mode, $args );
1790 $args[] = absint( $limit );
1791
1792 $sql = "SELECT * FROM %i
1793 WHERE payment_status IN ('completed', 'partially_refunded')
1794 {$extra}
1795 ORDER BY created_at DESC
1796 LIMIT %d";
1797
1798 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args.
1799 $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1800
1801 if ( ! $results || ! is_array( $results ) ) {
1802 return [];
1803 }
1804
1805 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1806 }
1807
1808 /**
1809 * Get top campaigns by donations.
1810 *
1811 * @param int $limit Number of campaigns to retrieve.
1812 * @param string $currency Currency code to scope to ('' for no filter).
1813 * @param string $payment_mode 'test' or 'live' ('' for no filter).
1814 * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats.
1815 * @since 0.0.1
1816 */
1817 public static function get_top_campaigns( $limit = 5, $currency = '', $payment_mode = '' ) {
1818 $instance = self::get_instance();
1819 global $wpdb;
1820
1821 $args = [ $instance->get_tablename(), SUREDONATION_POST_TYPE ];
1822 $extra = self::scope_fragment( $currency, $payment_mode, $args );
1823 $args[] = absint( $limit );
1824
1825 // The join is what makes LIMIT meaningful: orphaned campaign_ids (post
1826 // deleted, donations kept) still carry donations, so filtering them in
1827 // PHP after a SQL LIMIT returned fewer than the requested top-N while
1828 // valid campaigns sat below the cut.
1829 $sql = "SELECT
1830 d.campaign_id,
1831 p.post_title AS campaign_title,
1832 COUNT(*) as donation_count,
1833 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1834 COUNT(DISTINCT donor_email) as unique_donors
1835 FROM %i AS d
1836 INNER JOIN {$wpdb->posts} AS p
1837 ON p.ID = d.campaign_id
1838 AND p.post_type = %s
1839 WHERE payment_status IN ('completed', 'partially_refunded')
1840 {$extra}
1841 GROUP BY d.campaign_id
1842 ORDER BY total_raised DESC
1843 LIMIT %d";
1844
1845 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args.
1846 $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1847
1848 return $results ? $results : [];
1849 }
1850
1851 /**
1852 * Get donation trends over time.
1853 *
1854 * @param string $after Start date (ISO format).
1855 * @param string $before End date (ISO format).
1856 * @param string $group Grouping: 'day', 'week', or 'month'.
1857 * @param string $currency Currency code to scope to ('' for no currency filter).
1858 * @param int $campaign_id Campaign to scope to (0 for all campaigns).
1859 * @param string $payment_mode 'test' or 'live' ('' for no filter).
1860 * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends.
1861 * @since 0.0.1
1862 */
1863 public static function get_donation_trends( $after = '', $before = '', $group = 'day', $currency = '', $campaign_id = 0, $payment_mode = '' ) {
1864 $instance = self::get_instance();
1865 global $wpdb;
1866
1867 // Default to last 30 days if no dates provided.
1868 if ( empty( $after ) ) {
1869 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
1870 }
1871 if ( empty( $before ) ) {
1872 $before = gmdate( 'Y-m-d' );
1873 }
1874
1875 // Determine date format based on grouping.
1876 switch ( $group ) {
1877 case 'month':
1878 $date_format = '%Y-%m-01';
1879 break;
1880 case 'week':
1881 $date_format = '%x-%v'; // ISO year-week.
1882 break;
1883 case 'day':
1884 default:
1885 $date_format = '%Y-%m-%d';
1886 break;
1887 }
1888
1889 // Amounts of different currencies cannot be summed into one figure, so
1890 // scope the query to a single currency. Callers that don't care still
1891 // get coherent numbers because the default is the store currency.
1892 $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : '';
1893 $extra = '';
1894 $args = [ $date_format, $instance->get_tablename(), $after, $before ];
1895
1896 if ( '' !== $currency ) {
1897 $extra .= ' AND currency = %s';
1898 $args[] = $currency;
1899 }
1900
1901 if ( $campaign_id > 0 ) {
1902 $extra .= ' AND campaign_id = %d';
1903 $args[] = absint( $campaign_id );
1904 }
1905
1906 // Test and live donations must not be summed together either.
1907 $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : '';
1908 if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1909 $extra .= ' AND payment_mode = %s';
1910 $args[] = $payment_mode;
1911 }
1912
1913 $sql = "SELECT
1914 DATE_FORMAT(created_at, %s) as period,
1915 COUNT(*) as donation_count,
1916 COALESCE(SUM(amount - refunded_amount), 0) as total_amount
1917 FROM %i
1918 WHERE payment_status IN ('completed', 'partially_refunded')
1919 AND DATE(created_at) >= %s
1920 AND DATE(created_at) <= %s
1921 {$extra}
1922 GROUP BY period
1923 ORDER BY period ASC";
1924
1925 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value is passed through prepare args.
1926 $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
1927
1928 return $results ? $results : [];
1929 }
1930
1931 /**
1932 * Count donations recorded through a donation form, in any status.
1933 *
1934 * Used to protect a form from permanent deletion while donation rows still
1935 * reference it, mirroring count_by_campaign()'s role for campaigns.
1936 *
1937 * @param int $form_id Donation form post ID.
1938 * @return int Donation count.
1939 * @since 1.5.0
1940 */
1941 public static function count_by_form( $form_id ) {
1942 $instance = self::get_instance();
1943 global $wpdb;
1944
1945 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Guard on a destructive action; must read live data.
1946 $count = $wpdb->get_var(
1947 $wpdb->prepare(
1948 'SELECT COUNT(*) FROM %i WHERE form_id = %d',
1949 $instance->get_tablename(),
1950 absint( $form_id )
1951 )
1952 );
1953
1954 return is_numeric( $count ) ? (int) $count : 0;
1955 }
1956
1957 /**
1958 * Get completed entry count and revenue for a single donation form.
1959 *
1960 * @param int $form_id Donation form post ID.
1961 * @return array{entries: int, revenue: float} Form totals.
1962 * @since 1.5.0
1963 */
1964 public static function get_form_stats( $form_id ) {
1965 $instance = self::get_instance();
1966 global $wpdb;
1967
1968 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live totals; caching would show stale figures.
1969 $result = $wpdb->get_row(
1970 $wpdb->prepare(
1971 'SELECT COUNT(*) as entries, COALESCE(SUM(amount - refunded_amount), 0) as revenue FROM %i WHERE form_id = %d AND payment_status = %s',
1972 $instance->get_tablename(),
1973 absint( $form_id ),
1974 'completed'
1975 ),
1976 ARRAY_A
1977 );
1978
1979 return [
1980 'entries' => is_array( $result ) ? (int) ( $result['entries'] ?? 0 ) : 0,
1981 'revenue' => is_array( $result ) ? (float) ( $result['revenue'] ?? 0 ) : 0.0,
1982 ];
1983 }
1984
1985 /**
1986 * Get entry and revenue totals for several forms in one query.
1987 *
1988 * get_form_stats() is a per-form query, so formatting a page of N forms ran
1989 * N COUNT/SUM queries. This collapses that to one GROUP BY for the page.
1990 *
1991 * @param array<int> $form_ids Form IDs to total.
1992 * @return array<int, array{entries: int, revenue: float}> Totals keyed by form ID; every requested ID is present.
1993 * @since 1.5.0
1994 */
1995 public static function get_form_stats_bulk( array $form_ids ) {
1996 // intval, not absint: absint( -1 ) is 1, which would silently total a
1997 // real form the caller never asked about.
1998 $ids = array_values(
1999 array_unique(
2000 array_filter(
2001 array_map( 'intval', $form_ids ),
2002 static function ( $id ) {
2003 return $id > 0;
2004 }
2005 )
2006 )
2007 );
2008
2009 // Every requested id gets an entry, so callers never have to special-case
2010 // a form that simply has no donations yet.
2011 $stats = [];
2012 foreach ( $ids as $id ) {
2013 $stats[ $id ] = [
2014 'entries' => 0,
2015 'revenue' => 0.0,
2016 ];
2017 }
2018
2019 if ( empty( $ids ) ) {
2020 return $stats;
2021 }
2022
2023 $instance = self::get_instance();
2024 global $wpdb;
2025
2026 $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
2027
2028 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Live totals; placeholders are generated from a count, every value is bound.
2029 $rows = $wpdb->get_results(
2030 $wpdb->prepare(
2031 sprintf(
2032 'SELECT form_id, COUNT(*) as entries, COALESCE(SUM(amount - refunded_amount), 0) as revenue FROM %%i WHERE form_id IN ( %s ) AND payment_status = %%s GROUP BY form_id',
2033 $placeholders
2034 ),
2035 array_merge( [ $instance->get_tablename() ], $ids, [ 'completed' ] )
2036 ),
2037 ARRAY_A
2038 );
2039
2040 if ( ! is_array( $rows ) ) {
2041 return $stats;
2042 }
2043
2044 foreach ( $rows as $row ) {
2045 if ( ! is_array( $row ) ) {
2046 continue;
2047 }
2048
2049 $form_id = absint( $row['form_id'] ?? 0 );
2050 if ( ! isset( $stats[ $form_id ] ) ) {
2051 continue;
2052 }
2053
2054 $stats[ $form_id ] = [
2055 'entries' => (int) ( $row['entries'] ?? 0 ),
2056 'revenue' => (float) ( $row['revenue'] ?? 0 ),
2057 ];
2058 }
2059
2060 return $stats;
2061 }
2062
2063 /**
2064 * Count donations matching the admin-list filters.
2065 *
2066 * Mirrors get_admin_list()'s WHERE clause, including the search term. The
2067 * older get_total_donations_by_status() ignores `$search`, so any searched
2068 * listing reported the unfiltered total and paginated against it.
2069 *
2070 * @param string $status Payment status filter ('all' for no filter).
2071 * @param int $campaign_id Campaign ID filter (0 for no filter).
2072 * @param string $search Search term for donor_name, donor_email, or transaction_id.
2073 * @return int Matching row count.
2074 * @since 1.5.0
2075 */
2076 public static function count_admin_list( $status = 'all', $campaign_id = 0, $search = '' ) {
2077 $instance = self::get_instance();
2078 global $wpdb;
2079
2080 $conditions = [ '1=1' ];
2081 $args = [ $instance->get_tablename() ];
2082
2083 if ( 'all' !== $status ) {
2084 $conditions[] = 'payment_status = %s';
2085 $args[] = sanitize_text_field( $status );
2086 } else {
2087 // Mirrors get_admin_list(): abandoned rows are out of the unfiltered
2088 // listing, so the total has to leave them out too or the last page
2089 // comes back short.
2090 $conditions[] = "payment_status != 'abandoned'";
2091 }
2092
2093 if ( $campaign_id > 0 ) {
2094 $conditions[] = 'campaign_id = %d';
2095 $args[] = absint( $campaign_id );
2096 }
2097
2098 if ( ! empty( $search ) ) {
2099 $conditions[] = '(donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s)';
2100 $term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
2101 $args[] = $term;
2102 $args[] = $term;
2103 $args[] = $term;
2104 }
2105
2106 $where = implode( ' AND ', $conditions );
2107
2108 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args.
2109 $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", $args ) );
2110
2111 return is_numeric( $count ) ? (int) $count : 0;
2112 }
2113
2114 /**
2115 * Get recent donations for a campaign.
2116 *
2117 * @param int $campaign_id Campaign ID.
2118 * @param int $limit Number of donations to retrieve.
2119 * @return array<mixed> Array of recent donations.
2120 * @since 0.0.1
2121 */
2122 public static function get_recent_donations( $campaign_id, $limit = 5 ) {
2123 $instance = self::get_instance();
2124 global $wpdb;
2125
2126 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2127 $results = $wpdb->get_results(
2128 $wpdb->prepare(
2129 "SELECT * FROM %i WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
2130 $instance->get_tablename(),
2131 absint( $campaign_id ),
2132 absint( $limit )
2133 ),
2134 ARRAY_A
2135 );
2136
2137 if ( ! $results || ! is_array( $results ) ) {
2138 return [];
2139 }
2140
2141 return array_map( [ $instance, 'decode_by_datatype' ], $results );
2142 }
2143
2144 /**
2145 * Get paginated donations for a specific donor.
2146 *
2147 * @param int $donor_id Donor ID.
2148 * @param int $limit Number of records to return.
2149 * @param int $offset Offset for pagination.
2150 * @return array{donations: array<int, array<string, mixed>>, total: int} Paginated donations and total count.
2151 * @since 1.0.0
2152 */
2153 public static function get_by_donor_id( $donor_id, $limit = 10, $offset = 0 ) {
2154 if ( empty( $donor_id ) ) {
2155 return [
2156 'donations' => [],
2157 'total' => 0,
2158 ];
2159 }
2160
2161 $instance = self::get_instance();
2162 global $wpdb;
2163 $table = $instance->get_tablename();
2164
2165 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2166
2167 $total = $wpdb->get_var(
2168 $wpdb->prepare(
2169 'SELECT COUNT(*) FROM %i WHERE donor_id = %d',
2170 $table,
2171 absint( $donor_id )
2172 )
2173 );
2174
2175 $results = $wpdb->get_results(
2176 $wpdb->prepare(
2177 'SELECT * FROM %i WHERE donor_id = %d ORDER BY created_at DESC LIMIT %d, %d',
2178 $table,
2179 absint( $donor_id ),
2180 absint( $offset ),
2181 absint( $limit )
2182 ),
2183 ARRAY_A
2184 );
2185
2186 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2187
2188 if ( ! $results || ! is_array( $results ) ) {
2189 $results = [];
2190 }
2191
2192 return [
2193 'donations' => array_map( [ $instance, 'decode_by_datatype' ], $results ),
2194 'total' => is_numeric( $total ) ? (int) $total : 0,
2195 ];
2196 }
2197
2198 /**
2199 * Get donation activity data for a specific donor (for chart).
2200 *
2201 * @param int $donor_id Donor ID.
2202 * @param string $after Start date (Y-m-d).
2203 * @param string $before End date (Y-m-d).
2204 * @return array{chart_data: array<int, array{date: string, amount: float}>, stats: array{lifetime: float, highest: float, average: float}} Activity data.
2205 * @since 1.0.0
2206 */
2207 public static function get_donor_activity( $donor_id, $after = '', $before = '' ) {
2208 if ( empty( $donor_id ) ) {
2209 return [
2210 'chart_data' => [],
2211 'stats' => [
2212 'lifetime' => 0,
2213 'highest' => 0,
2214 'average' => 0,
2215 ],
2216 ];
2217 }
2218
2219 $instance = self::get_instance();
2220 global $wpdb;
2221 $table = $instance->get_tablename();
2222
2223 // Default date range: last 30 days.
2224 if ( empty( $after ) ) {
2225 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
2226 }
2227 if ( empty( $before ) ) {
2228 $before = gmdate( 'Y-m-d' );
2229 }
2230
2231 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2232
2233 // Chart data: donations grouped by date.
2234 $chart_data = $wpdb->get_results(
2235 $wpdb->prepare(
2236 "SELECT DATE(created_at) as date, COALESCE(SUM(amount), 0) as amount
2237 FROM %i
2238 WHERE donor_id = %d
2239 AND payment_status IN ('completed', 'partially_refunded')
2240 AND DATE(created_at) >= %s
2241 AND DATE(created_at) <= %s
2242 GROUP BY DATE(created_at)
2243 ORDER BY date ASC",
2244 $table,
2245 absint( $donor_id ),
2246 $after,
2247 $before
2248 ),
2249 ARRAY_A
2250 );
2251
2252 // Lifetime stats for this donor.
2253 $stats = $wpdb->get_row(
2254 $wpdb->prepare(
2255 "SELECT
2256 COALESCE(SUM(amount - refunded_amount), 0) as lifetime,
2257 COALESCE(MAX(amount), 0) as highest,
2258 COALESCE(AVG(amount), 0) as average
2259 FROM %i
2260 WHERE donor_id = %d AND payment_status IN ('completed', 'partially_refunded')",
2261 $table,
2262 absint( $donor_id )
2263 ),
2264 ARRAY_A
2265 );
2266
2267 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2268
2269 $stats = is_array( $stats ) ? $stats : [];
2270
2271 return [
2272 'chart_data' => is_array( $chart_data ) ? $chart_data : [],
2273 'stats' => [
2274 'lifetime' => is_numeric( $stats['lifetime'] ?? 0 ) ? round( (float) ( $stats['lifetime'] ?? 0 ), 2 ) : 0,
2275 'highest' => is_numeric( $stats['highest'] ?? 0 ) ? round( (float) ( $stats['highest'] ?? 0 ), 2 ) : 0,
2276 'average' => is_numeric( $stats['average'] ?? 0 ) ? round( (float) ( $stats['average'] ?? 0 ), 2 ) : 0,
2277 ],
2278 ];
2279 }
2280
2281 /**
2282 * Update donation status.
2283 *
2284 * @param int $donation_id Donation ID.
2285 * @param string $status New status.
2286 * @return int|false Number of rows updated or false on error.
2287 * @since 0.0.1
2288 */
2289 public static function update_status( $donation_id, $status ) {
2290 if ( empty( $donation_id ) || ! in_array( $status, self::$valid_statuses, true ) ) {
2291 return false;
2292 }
2293
2294 return self::update( $donation_id, [ 'payment_status' => $status ] );
2295 }
2296
2297 /**
2298 * Get valid payment statuses.
2299 *
2300 * @return array<string> Valid statuses.
2301 * @since 0.0.1
2302 */
2303 public static function get_valid_statuses() {
2304 return self::$valid_statuses;
2305 }
2306
2307 /**
2308 * Get valid donor-comment moderation statuses.
2309 *
2310 * @return array<string> Valid donor-comment statuses.
2311 * @since 1.6.0
2312 */
2313 public static function get_valid_comment_statuses() {
2314 return self::$valid_comment_statuses;
2315 }
2316
2317 /**
2318 * Resolve the moderation status a newly captured donor comment should get.
2319 *
2320 * Held for review only when the site owner has opted in; otherwise comments
2321 * publish straight away, matching how GiveWP and Charitable behave out of the
2322 * box. An empty comment gets `approved` so a donation with nothing to moderate
2323 * never shows up in a review queue.
2324 *
2325 * @param string $comment The captured comment.
2326 * @return string One of self::$valid_comment_statuses.
2327 * @since 1.6.0
2328 */
2329 public static function initial_comment_status( $comment ) {
2330 if ( '' === trim( Helper::get_string_value( $comment ) ) ) {
2331 return 'approved';
2332 }
2333
2334 $donor_settings = Helper::get_array_value(
2335 Helper::get_suredonation_option( \SureDonation\Inc\API\Settings_API::DONOR_OPTION_KEY, [] )
2336 );
2337
2338 return ! empty( $donor_settings['hold_donor_comments'] ) ? 'pending' : 'approved';
2339 }
2340
2341 /**
2342 * Add a log entry to a donation.
2343 *
2344 * @param int $donation_id Donation ID.
2345 * @param string $action Action type (e.g., 'status_change', 'refund', 'webhook').
2346 * @param string $message Log message.
2347 * @param array<string, mixed> $data Optional additional data.
2348 * @return int|false Number of rows updated or false on error.
2349 * @since 0.0.1
2350 */
2351 public static function add_log( $donation_id, $action, $message, $data = [] ) {
2352 if ( empty( $donation_id ) ) {
2353 return false;
2354 }
2355
2356 $donation = self::get( $donation_id );
2357 if ( ! $donation ) {
2358 return false;
2359 }
2360
2361 // Get existing log or initialize empty array.
2362 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
2363 $log_data = $donation['log'] ?? [];
2364 if ( is_array( $log_data ) ) {
2365 $log = $log_data;
2366 } elseif ( is_string( $log_data ) && ! empty( $log_data ) ) {
2367 $log = json_decode( $log_data, true );
2368 if ( ! is_array( $log ) ) {
2369 $log = [];
2370 }
2371 } else {
2372 $log = [];
2373 }
2374
2375 // Add new log entry.
2376 $log[] = [
2377 'action' => sanitize_text_field( $action ),
2378 'message' => sanitize_text_field( $message ),
2379 'data' => $data,
2380 'timestamp' => current_time( 'mysql' ),
2381 ];
2382
2383 return self::update( $donation_id, [ 'log' => $log ] );
2384 }
2385
2386 /**
2387 * Get log entries for a donation.
2388 *
2389 * @param int $donation_id Donation ID.
2390 * @return array<int, array<string, mixed>> Log entries.
2391 * @since 0.0.1
2392 */
2393 public static function get_log( $donation_id ) {
2394 if ( empty( $donation_id ) ) {
2395 return [];
2396 }
2397
2398 $donation = self::get( $donation_id );
2399 if ( ! $donation || empty( $donation['log'] ) ) {
2400 return [];
2401 }
2402
2403 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
2404 $log_data = $donation['log'];
2405 if ( is_array( $log_data ) ) {
2406 return $log_data;
2407 }
2408
2409 if ( is_string( $log_data ) ) {
2410 $log = json_decode( $log_data, true );
2411 return is_array( $log ) ? $log : [];
2412 }
2413
2414 return [];
2415 }
2416
2417 /**
2418 * Add refund data to donation_data for audit trail and duplicate prevention.
2419 *
2420 * Stores each refund with its ID as the key for O(1) lookups.
2421 *
2422 * @param int $donation_id Donation ID.
2423 * @param array<string, mixed> $refund_data Refund data to store.
2424 * @return bool True on success, false on failure.
2425 * @since 0.0.1
2426 */
2427 public static function add_refund_to_donation_data( $donation_id, $refund_data ) {
2428 $refund_id = $refund_data['refund_id'] ?? '';
2429
2430 if ( empty( $refund_id ) || empty( $donation_id ) ) {
2431 return false;
2432 }
2433
2434 $donation = self::get( $donation_id );
2435 if ( ! $donation ) {
2436 return false;
2437 }
2438
2439 // Get existing donation_data.
2440 $donation_data = $donation['donation_data'] ?? [];
2441 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2442 $donation_data = json_decode( $donation_data, true );
2443 }
2444 if ( ! is_array( $donation_data ) ) {
2445 $donation_data = [];
2446 }
2447
2448 // Initialize refunds array if not exists.
2449 if ( ! isset( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
2450 $donation_data['refunds'] = [];
2451 }
2452
2453 // Store with refund ID as key for O(1) lookup (duplicate prevention).
2454 $donation_data['refunds'][ $refund_id ] = $refund_data;
2455
2456 // Update donation_data in database.
2457 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2458
2459 return false !== $result;
2460 }
2461
2462 /**
2463 * Store the submitted form field values under the donation_data['fields'] key.
2464 *
2465 * The donation_data column is shared JSON (also holds refunds, notes and
2466 * subscription metadata), so the field data is merged under a dedicated
2467 * 'fields' key and never overwrites the column.
2468 *
2469 * Fields are written at donation creation (before the payment is confirmed)
2470 * and are intentionally retained for abandoned/failed donations — pending
2471 * records are legitimate business data (recovery, reconciliation, reporting).
2472 * There is deliberately no automatic PII purge here; erasure is handled on
2473 * demand via the admin delete actions (and can be wired to WordPress's
2474 * personal-data eraser hooks if a retention policy is later required).
2475 *
2476 * @param int $donation_id Donation ID.
2477 * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs.
2478 * @return bool True on success, false on failure.
2479 * @since 1.1.1
2480 */
2481 public static function set_submitted_fields( $donation_id, $field_data ) {
2482 if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) {
2483 return false;
2484 }
2485
2486 $donation = self::get( $donation_id );
2487 if ( ! $donation ) {
2488 return false;
2489 }
2490
2491 // Get existing donation_data.
2492 $donation_data = $donation['donation_data'] ?? [];
2493 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2494 $donation_data = json_decode( $donation_data, true );
2495 }
2496 if ( ! is_array( $donation_data ) ) {
2497 $donation_data = [];
2498 }
2499
2500 // Merge under a dedicated key — never overwrite the shared column.
2501 $donation_data['fields'] = $field_data;
2502
2503 // Update donation_data in database.
2504 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2505
2506 return false !== $result;
2507 }
2508
2509 /**
2510 * Check if a refund already exists in the donation data.
2511 *
2512 * This prevents duplicate processing of the same refund.
2513 *
2514 * @param int $donation_id Donation ID.
2515 * @param string $refund_id Refund ID to check.
2516 * @return bool True if refund already exists, false otherwise.
2517 * @since 0.0.1
2518 */
2519 public static function check_refund_exists( $donation_id, $refund_id ) {
2520 if ( empty( $donation_id ) || empty( $refund_id ) ) {
2521 return false;
2522 }
2523
2524 $donation = self::get( $donation_id );
2525 if ( ! $donation ) {
2526 return false;
2527 }
2528
2529 // Get donation_data and parse if needed.
2530 $donation_data = $donation['donation_data'] ?? [];
2531 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2532 $donation_data = json_decode( $donation_data, true );
2533 }
2534 if ( ! is_array( $donation_data ) ) {
2535 return false;
2536 }
2537
2538 // Check if refunds array exists and contains this refund ID.
2539 if ( empty( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
2540 return false;
2541 }
2542
2543 // O(1) lookup using refund ID as array key.
2544 return isset( $donation_data['refunds'][ $refund_id ] );
2545 }
2546
2547 /**
2548 * Add a note to a donation.
2549 *
2550 * @param int $donation_id Donation ID.
2551 * @param string $note_content Note content.
2552 * @param int $author_id Author user ID.
2553 * @return array{success: bool, note_id: string|null} Result with success status and note ID.
2554 * @since 0.0.1
2555 */
2556 public static function add_note( $donation_id, $note_content, $author_id = 0 ) {
2557 $result = [
2558 'success' => false,
2559 'note_id' => null,
2560 ];
2561
2562 if ( empty( $donation_id ) || empty( $note_content ) ) {
2563 return $result;
2564 }
2565
2566 $donation = self::get( $donation_id );
2567 if ( ! $donation ) {
2568 return $result;
2569 }
2570
2571 // Get existing donation_data.
2572 $donation_data = $donation['donation_data'] ?? [];
2573 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2574 $donation_data = json_decode( $donation_data, true );
2575 }
2576 if ( ! is_array( $donation_data ) ) {
2577 $donation_data = [];
2578 }
2579
2580 // Initialize notes array if not exists.
2581 if ( ! isset( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
2582 $donation_data['notes'] = [];
2583 }
2584
2585 // Generate unique note ID.
2586 $note_id = uniqid( 'note_', true );
2587
2588 // Get author info.
2589 $author_name = __( 'System', 'suredonation' );
2590 if ( $author_id > 0 ) {
2591 $user = get_userdata( $author_id );
2592 if ( $user ) {
2593 $author_name = $user->display_name;
2594 }
2595 }
2596
2597 // Add new note.
2598 $donation_data['notes'][ $note_id ] = [
2599 'id' => $note_id,
2600 'content' => wp_kses_post( $note_content ),
2601 'author_id' => $author_id,
2602 'author_name' => $author_name,
2603 'created_at' => current_time( 'mysql' ),
2604 ];
2605
2606 // Update donation_data in database.
2607 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2608
2609 if ( false !== $update_result ) {
2610 $result['success'] = true;
2611 $result['note_id'] = $note_id;
2612 }
2613
2614 return $result;
2615 }
2616
2617 /**
2618 * Get notes for a donation with pagination.
2619 *
2620 * @param int $donation_id Donation ID.
2621 * @param int $page Current page (1-indexed).
2622 * @param int $per_page Notes per page.
2623 * @return array{notes: array<int, array<string, mixed>>, total: int, total_pages: int} Paginated notes.
2624 * @since 0.0.1
2625 */
2626 public static function get_notes( $donation_id, $page = 1, $per_page = 3 ) {
2627 $result = [
2628 'notes' => [],
2629 'total' => 0,
2630 'total_pages' => 0,
2631 ];
2632
2633 if ( empty( $donation_id ) ) {
2634 return $result;
2635 }
2636
2637 $donation = self::get( $donation_id );
2638 if ( ! $donation ) {
2639 return $result;
2640 }
2641
2642 // Get donation_data and parse if needed.
2643 $donation_data = $donation['donation_data'] ?? [];
2644 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2645 $donation_data = json_decode( $donation_data, true );
2646 }
2647 if ( ! is_array( $donation_data ) ) {
2648 return $result;
2649 }
2650
2651 // Get notes array.
2652 if ( empty( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
2653 return $result;
2654 }
2655
2656 // Convert to array values and sort by created_at (newest first).
2657 $all_notes = array_values( $donation_data['notes'] );
2658 usort(
2659 $all_notes,
2660 static function ( $a, $b ) {
2661 return strtotime( $b['created_at'] ?? '0' ) - strtotime( $a['created_at'] ?? '0' );
2662 }
2663 );
2664
2665 $total = count( $all_notes );
2666 $total_pages = (int) ceil( $total / $per_page );
2667 $offset = ( $page - 1 ) * $per_page;
2668
2669 // Get paginated notes.
2670 $notes = array_slice( $all_notes, $offset, $per_page );
2671
2672 return [
2673 'notes' => $notes,
2674 'total' => $total,
2675 'total_pages' => $total_pages,
2676 ];
2677 }
2678
2679 /**
2680 * Delete a note from a donation.
2681 *
2682 * @param int $donation_id Donation ID.
2683 * @param string $note_id Note ID to delete.
2684 * @return bool True on success, false on failure.
2685 * @since 0.0.1
2686 */
2687 public static function delete_note( $donation_id, $note_id ) {
2688 if ( empty( $donation_id ) || empty( $note_id ) ) {
2689 return false;
2690 }
2691
2692 $donation = self::get( $donation_id );
2693 if ( ! $donation ) {
2694 return false;
2695 }
2696
2697 // Get donation_data and parse if needed.
2698 $donation_data = $donation['donation_data'] ?? [];
2699 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2700 $donation_data = json_decode( $donation_data, true );
2701 }
2702 if ( ! is_array( $donation_data ) ) {
2703 return false;
2704 }
2705
2706 // Check if note exists.
2707 if ( empty( $donation_data['notes'] ) || ! isset( $donation_data['notes'][ $note_id ] ) ) {
2708 return false;
2709 }
2710
2711 // Remove the note.
2712 unset( $donation_data['notes'][ $note_id ] );
2713
2714 // Update donation_data in database.
2715 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2716
2717 return false !== $result;
2718 }
2719
2720 /**
2721 * Remove a refund from donation_data.
2722 *
2723 * Used when a refund is canceled.
2724 *
2725 * @param int $donation_id Donation ID.
2726 * @param string $refund_id Refund ID to remove.
2727 * @return array{removed: bool, refund_data: array<string, mixed>|null} Result with removed status and refund data.
2728 * @since 0.0.1
2729 */
2730 public static function remove_refund_from_donation_data( $donation_id, $refund_id ) {
2731 $result = [
2732 'removed' => false,
2733 'refund_data' => null,
2734 ];
2735
2736 if ( empty( $donation_id ) || empty( $refund_id ) ) {
2737 return $result;
2738 }
2739
2740 $donation = self::get( $donation_id );
2741 if ( ! $donation ) {
2742 return $result;
2743 }
2744
2745 // Get donation_data and parse if needed.
2746 $donation_data = $donation['donation_data'] ?? [];
2747 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2748 $donation_data = json_decode( $donation_data, true );
2749 }
2750 if ( ! is_array( $donation_data ) ) {
2751 return $result;
2752 }
2753
2754 // Check if refund exists.
2755 if ( empty( $donation_data['refunds'] ) || ! isset( $donation_data['refunds'][ $refund_id ] ) ) {
2756 return $result;
2757 }
2758
2759 // Store the refund data before removing.
2760 $result['refund_data'] = $donation_data['refunds'][ $refund_id ];
2761
2762 // Remove the refund.
2763 unset( $donation_data['refunds'][ $refund_id ] );
2764
2765 // Update donation_data in database.
2766 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2767
2768 $result['removed'] = false !== $update_result;
2769
2770 return $result;
2771 }
2772 }
2773