PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / import / givewp / source.php

source.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/import/givewp/source.php

810 lines 24.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Read-only access to GiveWP data.
4 *
5 * Wraps all queries against GiveWP's tables (`give_forms`, `give_payment`
6 * post types plus their meta) so the rest of the importer never touches
7 * raw $wpdb against GiveWP-owned schemas. Keeps the surface area small if
8 * GiveWP ever restructures its data model.
9 *
10 * @package SureDonation
11 */
12
13 namespace SureDonation\Inc\Import\Givewp;
14
15 use SureDonation\Inc\Traits\Get_Instance;
16
17 // Exit if accessed directly.
18 defined( 'ABSPATH' ) || exit;
19
20 /**
21 * Source class.
22 *
23 * @since 1.0.0
24 */
25 class Source {
26 use Get_Instance;
27
28 /**
29 * Memoised result of has_givewp_data() — null until first call.
30 *
31 * @var bool|null
32 * @since 1.0.0
33 */
34 private $has_data_cache = null;
35
36 /**
37 * Whether this site holds GiveWP data we can migrate.
38 *
39 * We deliberately don't gate on the GiveWP plugin being active —
40 * every read in this class is raw $wpdb against GiveWP's tables
41 * (wp_give_donors, wp_give_subscriptions, wp_postmeta, wp_posts),
42 * so admins can migrate even after deactivating GiveWP. The
43 * canonical signal is "does the give_donors table exist?" — that
44 * table is created on activation and survives deactivation /
45 * uninstall unless the admin opts in to data deletion.
46 *
47 * Memoised for the request to keep the pre-flight count call
48 * cheap (one SHOW TABLES per page load instead of one per phase).
49 *
50 * @return bool
51 * @since 1.0.0
52 */
53 public function has_givewp_data() {
54 if ( null !== $this->has_data_cache ) {
55 return $this->has_data_cache;
56 }
57
58 global $wpdb;
59 $table = $wpdb->prefix . 'give_donors';
60
61 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-shot existence check for migration pre-flight.
62 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
63 $this->has_data_cache = ! empty( $exists );
64
65 return $this->has_data_cache;
66 }
67
68 /**
69 * Get the running GiveWP version, or null if the plugin isn't
70 * loaded. Migration itself doesn't depend on this — it's only
71 * surfaced as a "GiveWP X.Y" badge in the admin UI when available.
72 *
73 * @return string|null
74 * @since 1.0.0
75 */
76 public function get_givewp_version() {
77 return defined( 'GIVE_VERSION' ) ? (string) constant( 'GIVE_VERSION' ) : null;
78 }
79
80 /**
81 * Resolve which table + FK column to read donation meta from.
82 *
83 * Modern GiveWP installs store donation meta in `wp_give_donationmeta`
84 * (FK `donation_id` → `wp_posts.ID`). Older installs only have meta
85 * in `wp_postmeta` (FK `post_id`). GiveWP's `get_post_metadata`
86 * filter transparently bridges the two at runtime — but only while
87 * the plugin is active, and we run raw SQL that bypasses the filter
88 * regardless. Detect the custom table once per call and let raw
89 * queries target the right source directly.
90 *
91 * @return array{0:string,1:string} [table name, FK column name].
92 * @since 1.0.0
93 */
94 public function resolve_donation_meta_source() {
95 global $wpdb;
96
97 static $cache = null;
98 if ( null !== $cache ) {
99 return $cache;
100 }
101
102 $custom = $wpdb->prefix . 'give_donationmeta';
103 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
104 $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $custom ) );
105
106 $cache = $exists
107 ? [ $custom, 'donation_id' ]
108 : [ $wpdb->postmeta, 'post_id' ];
109
110 return $cache;
111 }
112
113 /**
114 * Get total count of GiveWP forms (campaigns).
115 *
116 * @return int
117 * @since 1.0.0
118 */
119 public function get_form_count() {
120 global $wpdb;
121
122 if ( ! $this->has_givewp_data() ) {
123 return 0;
124 }
125
126 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-shot read for migration pre-flight.
127 $count = $wpdb->get_var(
128 $wpdb->prepare(
129 "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status IN ('publish', 'draft', 'private')",
130 'give_forms'
131 )
132 );
133
134 return is_numeric( $count ) ? (int) $count : 0;
135 }
136
137 /**
138 * Get total count of GiveWP donations (payments).
139 *
140 * @return int
141 * @since 1.0.0
142 */
143 public function get_payment_count() {
144 global $wpdb;
145
146 if ( ! $this->has_givewp_data() ) {
147 return 0;
148 }
149
150 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-shot read for migration pre-flight.
151 $count = $wpdb->get_var(
152 $wpdb->prepare(
153 "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != %s",
154 'give_payment',
155 'trash'
156 )
157 );
158
159 return is_numeric( $count ) ? (int) $count : 0;
160 }
161
162 /**
163 * Get total count of GiveWP subscriptions.
164 *
165 * Subscriptions live in a custom give_subscriptions table created by the
166 * give-recurring add-on. Returns 0 if the table doesn't exist.
167 *
168 * @return int
169 * @since 1.0.0
170 */
171 public function get_subscription_count() {
172 global $wpdb;
173
174 if ( ! $this->has_givewp_data() ) {
175 return 0;
176 }
177
178 $table = $wpdb->prefix . 'give_subscriptions';
179
180 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Table name is a constant string.
181 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
182 if ( ! $exists ) {
183 return 0;
184 }
185
186 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
187 $count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) );
188
189 return is_numeric( $count ) ? (int) $count : 0;
190 }
191
192 /**
193 * Get total count of standalone GiveWP donors (donors without donations).
194 *
195 * @return int
196 * @since 1.0.0
197 */
198 public function get_standalone_donor_count() {
199 global $wpdb;
200
201 if ( ! $this->has_givewp_data() ) {
202 return 0;
203 }
204
205 $donors_table = $wpdb->prefix . 'give_donors';
206
207 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
208 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $donors_table ) );
209 if ( ! $exists ) {
210 return 0;
211 }
212
213 [ $meta_table, $meta_fk ] = $this->resolve_donation_meta_source();
214
215 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
216 $count = $wpdb->get_var(
217 $wpdb->prepare(
218 "SELECT COUNT(*) FROM %i d WHERE NOT EXISTS (
219 SELECT 1 FROM {$wpdb->posts} p
220 WHERE p.post_type = %s
221 AND p.post_status != %s
222 AND p.ID IN (
223 SELECT {$meta_fk} FROM {$meta_table}
224 WHERE meta_key = %s AND meta_value = d.id
225 )
226 )",
227 $donors_table,
228 'give_payment',
229 'trash',
230 '_give_payment_donor_id'
231 )
232 );
233
234 return is_numeric( $count ) ? (int) $count : 0;
235 }
236
237 /**
238 * Get a batch of GiveWP forms (campaigns).
239 *
240 * When `$form_ids` is a non-empty array, restricts the batch to those
241 * IDs (used by the two-step UI to migrate only campaigns the admin
242 * picked). An empty array returns nothing — callers should always
243 * pass an explicit selection now that the preview UI is the only
244 * entry point.
245 *
246 * @param int $offset Offset.
247 * @param int $limit Limit.
248 * @param int[] $form_ids Selected give_forms IDs.
249 * @return array<int,object> Array of post-row objects.
250 * @since 1.0.0
251 */
252 public function get_forms_batch( $offset, $limit, $form_ids = [] ) {
253 global $wpdb;
254
255 if ( ! $this->has_givewp_data() ) {
256 return [];
257 }
258
259 $form_ids = is_array( $form_ids )
260 ? array_values( array_unique( array_filter( array_map( 'absint', $form_ids ) ) ) )
261 : [];
262 if ( empty( $form_ids ) ) {
263 return [];
264 }
265
266 $placeholders = implode( ',', array_fill( 0, count( $form_ids ), '%d' ) );
267
268 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
269 $results = $wpdb->get_results(
270 $wpdb->prepare(
271 "SELECT * FROM {$wpdb->posts}
272 WHERE post_type = %s
273 AND post_status IN ('publish', 'draft', 'private')
274 AND ID IN ({$placeholders})
275 ORDER BY ID ASC
276 LIMIT %d OFFSET %d",
277 array_merge(
278 [ 'give_forms' ],
279 array_map( 'intval', $form_ids ),
280 [ absint( $limit ), absint( $offset ) ]
281 )
282 )
283 );
284
285 return is_array( $results ) ? $results : [];
286 }
287
288 /**
289 * Get a batch of GiveWP donations (payments) restricted to the given form IDs.
290 *
291 * Joins postmeta on `_give_payment_form_id` to keep only payments
292 * recorded against one of the selected GiveWP forms. An empty
293 * `$form_ids` returns nothing — same contract as `get_forms_batch()`.
294 *
295 * @param int $offset Offset.
296 * @param int $limit Limit.
297 * @param int[] $form_ids Selected give_forms IDs.
298 * @return array<int,object> Array of post-row objects.
299 * @since 1.0.0
300 */
301 public function get_payments_batch( $offset, $limit, $form_ids = [] ) {
302 global $wpdb;
303
304 if ( ! $this->has_givewp_data() ) {
305 return [];
306 }
307
308 $form_ids = is_array( $form_ids )
309 ? array_values( array_unique( array_filter( array_map( 'absint', $form_ids ) ) ) )
310 : [];
311 if ( empty( $form_ids ) ) {
312 return [];
313 }
314
315 // give_payment posts don't carry the form_id on the row itself —
316 // it lives in donation meta (`_give_payment_form_id`). Read
317 // directly from `give_donationmeta` when present so the filter
318 // also works while GiveWP is deactivated.
319 $placeholders = implode( ',', array_fill( 0, count( $form_ids ), '%d' ) );
320 [ $meta_table, $meta_fk ] = $this->resolve_donation_meta_source();
321
322 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
323 $results = $wpdb->get_results(
324 $wpdb->prepare(
325 "SELECT p.* FROM {$wpdb->posts} p
326 INNER JOIN {$meta_table} pm
327 ON pm.{$meta_fk} = p.ID
328 AND pm.meta_key = %s
329 AND CAST(pm.meta_value AS UNSIGNED) IN ({$placeholders})
330 WHERE p.post_type = %s
331 AND p.post_status != %s
332 ORDER BY p.ID ASC
333 LIMIT %d OFFSET %d",
334 array_merge(
335 [ '_give_payment_form_id' ],
336 array_map( 'intval', $form_ids ),
337 [ 'give_payment', 'trash', absint( $limit ), absint( $offset ) ]
338 )
339 )
340 );
341
342 return is_array( $results ) ? $results : [];
343 }
344
345 /**
346 * Get all meta for a given GiveWP payment as a flat assoc array.
347 *
348 * Reads `give_donationmeta` when GiveWP's custom-table store is
349 * present (the canonical location since GiveWP 2.x switched to it)
350 * and falls back to wp_postmeta otherwise. Going through raw SQL
351 * keeps this working when GiveWP is deactivated — `get_post_meta`
352 * relies on GiveWP's runtime filter to redirect post meta lookups
353 * into the custom table, and that filter isn't registered when the
354 * plugin is off.
355 *
356 * @param int $payment_id Payment post ID.
357 * @return array<string,string>
358 * @since 1.0.0
359 */
360 public function get_payment_meta( $payment_id ) {
361 global $wpdb;
362
363 $payment_id = absint( $payment_id );
364 if ( ! $payment_id ) {
365 return [];
366 }
367
368 [ $meta_table, $meta_fk ] = $this->resolve_donation_meta_source();
369
370 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
371 $rows = $wpdb->get_results(
372 $wpdb->prepare(
373 "SELECT meta_key, meta_value FROM {$meta_table} WHERE {$meta_fk} = %d",
374 $payment_id
375 ),
376 ARRAY_A
377 );
378
379 if ( ! is_array( $rows ) ) {
380 return [];
381 }
382
383 $flat = [];
384 foreach ( $rows as $row ) {
385 if ( ! is_array( $row ) || ! isset( $row['meta_key'] ) ) {
386 continue;
387 }
388 $flat[ (string) $row['meta_key'] ] = isset( $row['meta_value'] )
389 ? (string) $row['meta_value']
390 : '';
391 }
392
393 return $flat;
394 }
395
396 /**
397 * Get the donation count grouped by GiveWP gateway slug.
398 *
399 * Used by the data-found panel to show per-gateway breakdown with
400 * live/historical badges.
401 *
402 * @return array<int,array{slug:string,count:int}>
403 * @since 1.0.0
404 */
405 public function get_gateway_breakdown() {
406 global $wpdb;
407
408 if ( ! $this->has_givewp_data() ) {
409 return [];
410 }
411
412 [ $meta_table, $meta_fk ] = $this->resolve_donation_meta_source();
413
414 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
415 $rows = $wpdb->get_results(
416 $wpdb->prepare(
417 "SELECT pm.meta_value AS slug, COUNT(*) AS total
418 FROM {$meta_table} pm
419 INNER JOIN {$wpdb->posts} p ON p.ID = pm.{$meta_fk}
420 WHERE pm.meta_key = %s AND p.post_type = %s AND p.post_status != %s
421 GROUP BY pm.meta_value
422 ORDER BY total DESC",
423 '_give_payment_gateway',
424 'give_payment',
425 'trash'
426 ),
427 ARRAY_A
428 );
429
430 if ( ! is_array( $rows ) ) {
431 return [];
432 }
433
434 $out = [];
435 foreach ( $rows as $row ) {
436 $slug = isset( $row['slug'] ) ? (string) $row['slug'] : '';
437 if ( '' === $slug ) {
438 continue;
439 }
440 $out[] = [
441 'slug' => $slug,
442 'count' => isset( $row['total'] ) && is_numeric( $row['total'] ) ? (int) $row['total'] : 0,
443 ];
444 }
445
446 return $out;
447 }
448
449 /**
450 * Get a batch of GiveWP subscriptions restricted to the given form IDs.
451 *
452 * `give_subscriptions.product_id` holds the give_forms post ID this
453 * subscription was created against — same selection unit the rest of
454 * the migration uses. Empty `$form_ids` returns nothing.
455 *
456 * @param int $offset Offset.
457 * @param int $limit Limit.
458 * @param int[] $form_ids Selected give_forms IDs.
459 * @return array<int,object>
460 * @since 1.0.0
461 */
462 public function get_subscriptions_batch( $offset, $limit, $form_ids = [] ) {
463 global $wpdb;
464
465 if ( ! $this->has_givewp_data() ) {
466 return [];
467 }
468
469 $form_ids = is_array( $form_ids )
470 ? array_values( array_unique( array_filter( array_map( 'absint', $form_ids ) ) ) )
471 : [];
472 if ( empty( $form_ids ) ) {
473 return [];
474 }
475
476 $table = $wpdb->prefix . 'give_subscriptions';
477
478 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
479 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
480 if ( ! $exists ) {
481 return [];
482 }
483
484 $placeholders = implode( ',', array_fill( 0, count( $form_ids ), '%d' ) );
485
486 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
487 $results = $wpdb->get_results(
488 $wpdb->prepare(
489 "SELECT * FROM %i WHERE product_id IN ({$placeholders}) ORDER BY id ASC LIMIT %d OFFSET %d",
490 array_merge(
491 [ $table ],
492 array_map( 'intval', $form_ids ),
493 [ absint( $limit ), absint( $offset ) ]
494 )
495 )
496 );
497
498 return is_array( $results ) ? $results : [];
499 }
500
501
502 /**
503 * Get the GiveWP 4.x campaign row joined to a given form ID.
504 *
505 * GiveWP 4 stores rich campaign data (goal, descriptions, dates, colors)
506 * in a custom `give_campaigns` table that points at the legacy give_forms
507 * post via `form_id`. Older versions don't have this table — the lookup
508 * is best-effort and the caller falls back to post meta.
509 *
510 * @param int $form_id GiveWP form (post) ID.
511 * @return array<string,mixed>|null
512 * @since 1.0.0
513 */
514 public function get_campaign_for_form( $form_id ) {
515 global $wpdb;
516
517 $form_id = absint( $form_id );
518 if ( ! $form_id || ! $this->has_givewp_data() ) {
519 return null;
520 }
521
522 $table = $wpdb->prefix . 'give_campaigns';
523
524 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
525 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
526 if ( ! $exists ) {
527 return null;
528 }
529
530 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
531 $row = $wpdb->get_row(
532 $wpdb->prepare(
533 'SELECT * FROM %i WHERE form_id = %d LIMIT 1',
534 $table,
535 $form_id
536 ),
537 ARRAY_A
538 );
539
540 return is_array( $row ) ? $row : null;
541 }
542
543 /**
544 * Resolve the donation amount for a GiveWP payment from any of the
545 * known meta keys / serialised payment-meta payloads.
546 *
547 * GiveWP has stored donation amounts under several keys across major
548 * versions (_give_payment_total, _give_payment_amount, and inside the
549 * serialised _give_payment_meta blob's `price` field). Try them in
550 * order, return 0.0 if none yield a positive number.
551 *
552 * @param array<string,mixed> $meta Flat payment meta as returned by get_payment_meta().
553 * @return float
554 * @since 1.0.0
555 */
556 public function extract_donation_amount( $meta ) {
557 if ( ! is_array( $meta ) ) {
558 return 0.0;
559 }
560
561 $candidates = [ '_give_payment_total', '_give_payment_amount', '_give_cs_base_amount' ];
562 foreach ( $candidates as $key ) {
563 if ( isset( $meta[ $key ] ) && is_numeric( $meta[ $key ] ) ) {
564 $value = (float) $meta[ $key ];
565 if ( $value > 0 ) {
566 return $value;
567 }
568 }
569 }
570
571 // Fall back to the legacy serialised _give_payment_meta blob.
572 if ( isset( $meta['_give_payment_meta'] ) && is_string( $meta['_give_payment_meta'] ) && is_serialized( $meta['_give_payment_meta'] ) ) {
573 // allowed_classes=false blocks PHP object injection (CWE-502) on the GiveWP-controlled payload; @ suppresses notices on malformed-but-is_serialized() input (result is validated below).
574 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged -- hardened native unserialize with class instantiation disabled.
575 $unserialised = @unserialize( $meta['_give_payment_meta'], [ 'allowed_classes' => false ] );
576 if ( is_array( $unserialised ) && isset( $unserialised['price'] ) && is_numeric( $unserialised['price'] ) ) {
577 return (float) $unserialised['price'];
578 }
579 }
580
581 return 0.0;
582 }
583
584 /**
585 * Get a single GiveWP donor row by donor ID.
586 *
587 * @param int $give_donor_id GiveWP donor ID.
588 * @return array<string,mixed>|null
589 * @since 1.0.0
590 */
591 public function get_donor( $give_donor_id ) {
592 global $wpdb;
593
594 $give_donor_id = absint( $give_donor_id );
595 if ( ! $give_donor_id || ! $this->has_givewp_data() ) {
596 return null;
597 }
598
599 $table = $wpdb->prefix . 'give_donors';
600
601 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
602 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
603 if ( ! $exists ) {
604 return null;
605 }
606
607 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
608 $row = $wpdb->get_row(
609 $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d LIMIT 1', $table, $give_donor_id ),
610 ARRAY_A
611 );
612
613 return is_array( $row ) ? $row : null;
614 }
615
616 /**
617 * Get all give_donormeta rows for a donor as a flat assoc array.
618 *
619 * GiveWP stores donor-level fields the payment-meta path can't see
620 * (structured billing address, company, add-on opt-in flags, Stripe
621 * customer id, avatar URL, etc.) on this side. Mappers use this
622 * helper to enrich the donor row beyond what the payment meta
623 * surfaces.
624 *
625 * @param int $give_donor_id GiveWP donor ID.
626 * @return array<string,string>
627 * @since 1.0.0
628 */
629 public function get_donor_meta( $give_donor_id ) {
630 global $wpdb;
631
632 $give_donor_id = absint( $give_donor_id );
633 if ( ! $give_donor_id || ! $this->has_givewp_data() ) {
634 return [];
635 }
636
637 $table = $wpdb->prefix . 'give_donormeta';
638
639 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
640 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
641 if ( ! $exists ) {
642 return [];
643 }
644
645 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
646 $rows = $wpdb->get_results(
647 $wpdb->prepare(
648 'SELECT meta_key, meta_value FROM %i WHERE donor_id = %d',
649 $table,
650 $give_donor_id
651 ),
652 ARRAY_A
653 );
654
655 if ( ! is_array( $rows ) ) {
656 return [];
657 }
658
659 $out = [];
660 foreach ( $rows as $row ) {
661 if ( ! is_array( $row ) || ! isset( $row['meta_key'] ) ) {
662 continue;
663 }
664 $out[ (string) $row['meta_key'] ] = isset( $row['meta_value'] ) ? (string) $row['meta_value'] : '';
665 }
666 return $out;
667 }
668
669 /**
670 * Per-form aggregate preview for the two-step migration UI.
671 *
672 * Returns one row per give_forms post, summed across all linked
673 * give_payment rows (donations, distinct donors, total amount) plus a
674 * per-form subscription count and the form's display title (preferring
675 * the v4 `give_campaigns.title` when that table exists, falling back
676 * to `posts.post_title`).
677 *
678 * The standalone-donor count is returned alongside — those donors have
679 * no payments and therefore no form_id, so they're surfaced as a
680 * separate row in the UI rather than folded into a campaign.
681 *
682 * GiveWP 4 introduced a `give_campaigns` table that 1:1-maps to forms
683 * via a `form_id` column; we LEFT JOIN it so v3 sites just see the
684 * post_title and v4 sites see the richer campaign title.
685 *
686 * @return array{campaigns:array<int,array{form_id:int,title:string,donations:int,donors:int,subscriptions:int,total_amount:float}>,standalone_donors:int}
687 * @since 1.0.0
688 */
689 public function get_campaigns_preview() {
690 global $wpdb;
691
692 if ( ! $this->has_givewp_data() ) {
693 return [
694 'campaigns' => [],
695 'standalone_donors' => 0,
696 ];
697 }
698
699 $has_v4_campaigns = (bool) $wpdb->get_var(
700 $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->prefix . 'give_campaigns' )
701 );
702
703 // Per-form donation aggregates. JOIN postmeta on _give_payment_form_id
704 // → payment ID, then sum amounts + distinct donor IDs.
705 $campaigns_join = $has_v4_campaigns
706 ? "LEFT JOIN {$wpdb->prefix}give_campaigns c ON c.form_id = forms.ID"
707 : '';
708 $title_expr = $has_v4_campaigns
709 ? 'COALESCE(NULLIF(c.campaign_title, ""), forms.post_title)'
710 : 'forms.post_title';
711
712 // GiveWP stores donation meta in its own `give_donationmeta`
713 // custom table (FK donation_id → wp_posts.ID). wp_postmeta is
714 // populated transparently by GiveWP's get_post_metadata filter at
715 // runtime — but only when GiveWP is active, and raw SQL bypasses
716 // that filter regardless. Read from the custom table directly
717 // when it exists; fall back to wp_postmeta on legacy installs
718 // that pre-date the custom-table store.
719 [ $meta_table, $meta_fk ] = $this->resolve_donation_meta_source();
720
721 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
722 $rows = $wpdb->get_results(
723 $wpdb->prepare(
724 "SELECT
725 forms.ID AS form_id,
726 {$title_expr} AS title,
727 COUNT(DISTINCT p.ID) AS donations,
728 COUNT(DISTINCT pm_donor.meta_value) AS donors,
729 COALESCE(SUM(CAST(pm_total.meta_value AS DECIMAL(20,2))), 0) AS total_amount
730 FROM {$wpdb->posts} forms
731 {$campaigns_join}
732 LEFT JOIN {$meta_table} pm_form
733 ON pm_form.meta_key = %s
734 AND pm_form.meta_value = CAST(forms.ID AS CHAR)
735 LEFT JOIN {$wpdb->posts} p
736 ON p.ID = pm_form.{$meta_fk}
737 AND p.post_type = %s
738 AND p.post_status != %s
739 LEFT JOIN {$meta_table} pm_donor
740 ON pm_donor.{$meta_fk} = p.ID
741 AND pm_donor.meta_key = %s
742 LEFT JOIN {$meta_table} pm_total
743 ON pm_total.{$meta_fk} = p.ID
744 AND pm_total.meta_key = %s
745 WHERE forms.post_type = %s
746 AND forms.post_status IN ('publish', 'draft', 'private')
747 GROUP BY forms.ID
748 ORDER BY donations DESC, forms.post_title ASC",
749 '_give_payment_form_id',
750 'give_payment',
751 'trash',
752 '_give_payment_donor_id',
753 '_give_payment_total',
754 'give_forms'
755 ),
756 ARRAY_A
757 );
758
759 if ( ! is_array( $rows ) ) {
760 $rows = [];
761 }
762
763 // Per-form subscription counts, merged in afterwards so the main
764 // aggregate query stays purely on the payments side (subscriptions
765 // table doesn't exist on every site).
766 $subs_by_form = [];
767 $subs_table = $wpdb->prefix . 'give_subscriptions';
768 $subs_exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $subs_table ) );
769 if ( $subs_exists ) {
770 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
771 $sub_rows = $wpdb->get_results(
772 $wpdb->prepare(
773 'SELECT product_id AS form_id, COUNT(*) AS subscriptions FROM %i GROUP BY product_id',
774 $subs_table
775 ),
776 ARRAY_A
777 );
778 if ( is_array( $sub_rows ) ) {
779 foreach ( $sub_rows as $sr ) {
780 $fid = isset( $sr['form_id'] ) ? (int) $sr['form_id'] : 0;
781 if ( $fid > 0 ) {
782 $subs_by_form[ $fid ] = isset( $sr['subscriptions'] ) ? (int) $sr['subscriptions'] : 0;
783 }
784 }
785 }
786 }
787
788 $campaigns = [];
789 foreach ( $rows as $row ) {
790 $form_id = isset( $row['form_id'] ) ? (int) $row['form_id'] : 0;
791 if ( $form_id <= 0 ) {
792 continue;
793 }
794 $campaigns[] = [
795 'form_id' => $form_id,
796 'title' => isset( $row['title'] ) ? (string) $row['title'] : '',
797 'donations' => isset( $row['donations'] ) ? (int) $row['donations'] : 0,
798 'donors' => isset( $row['donors'] ) ? (int) $row['donors'] : 0,
799 'subscriptions' => isset( $subs_by_form[ $form_id ] ) ? $subs_by_form[ $form_id ] : 0,
800 'total_amount' => isset( $row['total_amount'] ) ? (float) $row['total_amount'] : 0.0,
801 ];
802 }
803
804 return [
805 'campaigns' => $campaigns,
806 'standalone_donors' => $this->get_standalone_donor_count(),
807 ];
808 }
809 }
810