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

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

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