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

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