| @@ -36,27 +36,11 @@ | ||
| 36 | 36 | * |
| 37 | 37 | * @var int |
| 38 | 38 | * @since 0.0.1 |
| 39 | 39 | */ |
| 40 | - protected $table_version = 7; | |
| 40 | + protected $table_version = 4; | |
| 41 | 41 | |
| 42 | 42 | /** |
| 43 | - * Valid donor-comment moderation statuses. | |
| 44 | - * | |
| 45 | - * `approved` comments are public; `pending` is awaiting review (only reachable | |
| 46 | - * when the "Hold donor comments for review" setting is on); `rejected` is | |
| 47 | - * hidden but kept, so a moderator's decision is not destructive. | |
| 48 | - * | |
| 49 | - * @var array<string> | |
| 50 | - * @since 1.6.0 | |
| 51 | - */ | |
| 52 | - private static $valid_comment_statuses = [ | |
| 53 | - 'approved', | |
| 54 | - 'pending', | |
| 55 | - 'rejected', | |
| 56 | - ]; | |
| 57 | - | |
| 58 | - /** | |
| 59 | 43 | * Valid payment statuses. |
| 60 | 44 | * |
| 61 | 45 | * @var array<string> |
| 62 | 46 | * @since 0.0.1 |
| @@ -69,14 +53,8 @@ | ||
| 69 | 53 | 'refunded', |
| 70 | 54 | 'partially_refunded', |
| 71 | 55 | 'cancelled', |
| 72 | 56 | 'suspicious', |
| 73 | - // Deliberately not 'failed'. A donor who closed the gateway window never | |
| 74 | - // attempted a payment, and collapsing the two destroys the signal we most | |
| 75 | - // need: our own capture failure rate. If abandonment and genuine failures | |
| 76 | - // share a status, "most donors walk away at the gateway" (a product | |
| 77 | - // problem) is indistinguishable from "our captures are breaking" (a bug). | |
| 78 | - 'abandoned', | |
| 79 | 57 | ]; |
| 80 | 58 | |
| 81 | 59 | /** |
| 82 | 60 | * Valid order columns. |
| @@ -139,12 +117,8 @@ | ||
| 139 | 117 | 'customer_id' => [ |
| 140 | 118 | 'type' => 'string', |
| 141 | 119 | 'default' => '', |
| 142 | 120 | ], |
| 143 | - 'stripe_account_id' => [ | |
| 144 | - 'type' => 'string', | |
| 145 | - 'default' => '', | |
| 146 | - ], | |
| 147 | 121 | 'gateway' => [ |
| 148 | 122 | 'type' => 'string', |
| 149 | 123 | 'default' => 'stripe', |
| 150 | 124 | ], |
| @@ -191,12 +165,8 @@ | ||
| 191 | 165 | 'donor_comment' => [ |
| 192 | 166 | 'type' => 'string', |
| 193 | 167 | 'default' => '', |
| 194 | 168 | ], |
| 195 | - 'donor_comment_status' => [ | |
| 196 | - 'type' => 'string', | |
| 197 | - 'default' => 'approved', | |
| 198 | - ], | |
| 199 | 169 | 'receipt_sent' => [ |
| 200 | 170 | 'type' => 'boolean', |
| 201 | 171 | 'default' => false, |
| 202 | 172 | ], |
| @@ -231,12 +201,8 @@ | ||
| 231 | 201 | 'import_source' => [ |
| 232 | 202 | 'type' => 'string', |
| 233 | 203 | 'default' => '', |
| 234 | 204 | ], |
| 235 | - 'import_provenance' => [ | |
| 236 | - 'type' => 'string', | |
| 237 | - 'default' => '', | |
| 238 | - ], | |
| 239 | 205 | 'created_at' => [ |
| 240 | 206 | 'type' => 'datetime', |
| 241 | 207 | ], |
| 242 | 208 | 'updated_at' => [ |
| @@ -259,9 +225,8 @@ | ||
| 259 | 225 | 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0', |
| 260 | 226 | 'currency VARCHAR(10) NOT NULL', |
| 261 | 227 | 'transaction_id VARCHAR(255) NOT NULL', |
| 262 | 228 | 'customer_id VARCHAR(50) NOT NULL', |
| 263 | - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\'', | |
| 264 | 229 | 'gateway VARCHAR(20) NOT NULL', |
| 265 | 230 | 'payment_status VARCHAR(50) NOT NULL', |
| 266 | 231 | 'payment_mode VARCHAR(20) NOT NULL', |
| 267 | 232 | 'donor_name VARCHAR(255) NOT NULL', |
| @@ -272,9 +237,8 @@ | ||
| 272 | 237 | 'subscription_id VARCHAR(255) NOT NULL', |
| 273 | 238 | 'subscription_status VARCHAR(30) NOT NULL', |
| 274 | 239 | 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0', |
| 275 | 240 | 'donor_comment TEXT', |
| 276 | - 'donor_comment_status VARCHAR(20) NOT NULL DEFAULT \'approved\'', | |
| 277 | 241 | 'receipt_sent TINYINT(1) NOT NULL DEFAULT 0', |
| 278 | 242 | 'receipt_pdf_url VARCHAR(255) NOT NULL', |
| 279 | 243 | 'donation_data LONGTEXT', |
| 280 | 244 | 'log LONGTEXT', |
| @@ -281,10 +245,9 @@ | ||
| 281 | 245 | 'ip_address VARCHAR(45) NOT NULL', |
| 282 | 246 | 'user_agent TEXT', |
| 283 | 247 | 'referer_url TEXT', |
| 284 | 248 | 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0', |
| 285 | - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'', | |
| 286 | - 'import_provenance VARCHAR(64) NOT NULL DEFAULT \'\'', | |
| 249 | + 'import_source VARCHAR(20) NOT NULL DEFAULT ""', | |
| 287 | 250 | 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', |
| 288 | 251 | 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', |
| 289 | 252 | 'INDEX idx_campaign (campaign_id)', |
| 290 | 253 | 'INDEX idx_donor (donor_id)', |
| @@ -295,10 +258,8 @@ | ||
| 295 | 258 | 'INDEX idx_subscription (subscription_id)', |
| 296 | 259 | 'INDEX idx_subscription_status (subscription_status)', |
| 297 | 260 | 'INDEX idx_parent_subscription (parent_subscription_id)', |
| 298 | 261 | 'INDEX idx_import_source (import_source_id, import_source)', |
| 299 | - 'INDEX idx_import_provenance (import_source, import_provenance)', | |
| 300 | - 'INDEX idx_stripe_account (stripe_account_id)', | |
| 301 | 262 | ]; |
| 302 | 263 | } |
| 303 | 264 | |
| 304 | 265 | /** |
| @@ -305,32 +266,10 @@ | ||
| 305 | 266 | * New columns added across versions. |
| 306 | 267 | * |
| 307 | 268 | * Version 2 added subscription support; version 4 added the |
| 308 | 269 | * source-agnostic pair `import_source_id` + `import_source` used by |
| 309 | - * the migration tool for duplicate detection and rollback; version 5 | |
| 310 | - * added `stripe_account_id` so donations record which connected Stripe | |
| 311 | - * account processed them (multiple Stripe accounts support); version 6 | |
| 312 | - * added `import_provenance` — an indexed `(donation_post_id, source_campaign_id)` | |
| 313 | - * key the Charitable importer dedupes on with a single indexed lookup per | |
| 314 | - * row, instead of scanning + JSON-decoding every prior imported row per batch; | |
| 315 | - * version 7 added `donor_comment_status`, defaulting to `approved` so | |
| 316 | - * comments that predate moderation stay visible. | |
| 270 | + * the migration tool for duplicate detection and rollback. | |
| 317 | 271 | * |
| 318 | - * Version 7 rather than 6: `import_provenance` had already taken 6 on dev | |
| 319 | - * while this branch was open, and the upgrade only runs when the number | |
| 320 | - * increases (Database\Base::set_db_upgradable()). Leaving both columns on 6 | |
| 321 | - * would mean any site already upgraded to 6 never receives | |
| 322 | - * `donor_comment_status`, while get_schema() still declares it and | |
| 323 | - * prepare_data() names every declared column in the INSERT — so every | |
| 324 | - * donation would fail with "Unknown column 'donor_comment_status'". | |
| 325 | - * | |
| 326 | - * No index accompanies `donor_comment_status`: it is `approved` on virtually | |
| 327 | - * every row, so a `(campaign_id, donor_comment_status)` index measured ~3% | |
| 328 | - * better than the existing `idx_campaign` on a 200k-row table and still | |
| 329 | - * filesorted, while adding write cost to the plugin's hottest table. Its one | |
| 330 | - * reader (Campaign_Stats::get_donor_comments()) is also behind a 5-minute | |
| 331 | - * transient. Revisit only if that query shows up in real profiling. | |
| 332 | - * | |
| 333 | 272 | * {@inheritDoc} |
| 334 | 273 | * |
| 335 | 274 | * @since 1.0.0 |
| 336 | 275 | */ |
| @@ -339,199 +278,17 @@ | ||
| 339 | 278 | 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type', |
| 340 | 279 | 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id', |
| 341 | 280 | 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status', |
| 342 | 281 | 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url', |
| 343 | - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\' AFTER import_source_id', | |
| 344 | - 'import_provenance VARCHAR(64) NOT NULL DEFAULT \'\' AFTER import_source', | |
| 345 | - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\' AFTER customer_id', | |
| 346 | - 'donor_comment_status VARCHAR(20) NOT NULL DEFAULT \'approved\' AFTER donor_comment', | |
| 282 | + 'import_source VARCHAR(20) NOT NULL DEFAULT "" AFTER import_source_id', | |
| 347 | 283 | 'INDEX idx_subscription (subscription_id)', |
| 348 | 284 | 'INDEX idx_subscription_status (subscription_status)', |
| 349 | 285 | 'INDEX idx_parent_subscription (parent_subscription_id)', |
| 350 | 286 | 'INDEX idx_import_source (import_source_id, import_source)', |
| 351 | - 'INDEX idx_import_provenance (import_source, import_provenance)', | |
| 352 | - 'INDEX idx_stripe_account (stripe_account_id)', | |
| 353 | 287 | ]; |
| 354 | 288 | } |
| 355 | 289 | |
| 356 | 290 | /** |
| 357 | - * One-time data migrations for the donations table. | |
| 358 | - * | |
| 359 | - * Each backfill is gated on the version being upgraded *into* (via | |
| 360 | - * $this->prev_version) so it runs exactly once, on the upgrade that adds the | |
| 361 | - * column, and is skipped on fresh installs (which create the column already | |
| 362 | - * populated / empty as appropriate) and on later upgrades. | |
| 363 | - * | |
| 364 | - * @return void | |
| 365 | - * @since 1.3.0 | |
| 366 | - */ | |
| 367 | - public function run_data_migrations() { | |
| 368 | - // A failed CREATE/ALTER earlier in this upgrade already cleared the flag; | |
| 369 | - // the column may not exist, so don't run an UPDATE against it. | |
| 370 | - if ( ! $this->db_upgradable ) { | |
| 371 | - return; | |
| 372 | - } | |
| 373 | - | |
| 374 | - if ( $this->prev_version < 5 ) { | |
| 375 | - $this->backfill_stripe_account_id(); | |
| 376 | - } | |
| 377 | - | |
| 378 | - if ( $this->prev_version < 6 ) { | |
| 379 | - $this->backfill_import_provenance(); | |
| 380 | - } | |
| 381 | - } | |
| 382 | - | |
| 383 | - /** | |
| 384 | - * Backfill `stripe_account_id` on the upgrade into v5. | |
| 385 | - * | |
| 386 | - * Before multi-account there could only be a single connected Stripe account, | |
| 387 | - * so every pre-v5 Stripe donation belongs to the current (single) default | |
| 388 | - * account. Backfill it so refunds and subscription lifecycle actions keep | |
| 389 | - * routing to the originating account after a second account is connected and | |
| 390 | - * the default is switched. Idempotent (touches only empty rows). | |
| 391 | - * | |
| 392 | - * @return void | |
| 393 | - * @since 1.3.0 | |
| 394 | - */ | |
| 395 | - private function backfill_stripe_account_id() { | |
| 396 | - if ( ! class_exists( '\SureDonation\Inc\Payments\Stripe\Stripe_Helper' ) ) { | |
| 397 | - return; | |
| 398 | - } | |
| 399 | - | |
| 400 | - // Runs during the v5 DB upgrade — before any second account can be | |
| 401 | - // connected via the UI — so the default is still the single legacy account. | |
| 402 | - $account_id = \SureDonation\Inc\Payments\Stripe\Stripe_Helper::get_default_account_id(); | |
| 403 | - if ( ! is_string( $account_id ) || '' === $account_id ) { | |
| 404 | - return; | |
| 405 | - } | |
| 406 | - | |
| 407 | - global $wpdb; | |
| 408 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill of a newly added column; not cacheable. | |
| 409 | - $result = $wpdb->query( | |
| 410 | - $wpdb->prepare( | |
| 411 | - 'UPDATE %i SET stripe_account_id = %s WHERE gateway = %s AND ( stripe_account_id = %s OR stripe_account_id IS NULL )', | |
| 412 | - $this->get_tablename(), | |
| 413 | - $account_id, | |
| 414 | - 'stripe', | |
| 415 | - '' | |
| 416 | - ) | |
| 417 | - ); | |
| 418 | - | |
| 419 | - // A transient failure (e.g. lock wait timeout on a busy table) must not | |
| 420 | - // persist the new version: `prev_version >= 5` would then skip this | |
| 421 | - // one-shot backfill forever. Leaving the version unwritten makes the | |
| 422 | - // idempotent sequence retry on the next request. | |
| 423 | - if ( false === $result ) { | |
| 424 | - $this->db_upgradable = false; | |
| 425 | - } | |
| 426 | - } | |
| 427 | - | |
| 428 | - /** | |
| 429 | - * Backfill `import_provenance` on the upgrade into v6. | |
| 430 | - * | |
| 431 | - * The Charitable importer moved its dedupe key out of a per-batch scan of | |
| 432 | - * `donation_data` and onto this indexed column. Rows imported before v6 have | |
| 433 | - * an empty key, so a re-import after upgrade would fail to match them and | |
| 434 | - * insert duplicates. Reconstruct the key from the stored | |
| 435 | - * `donation_data.charitable` block — the same `(donation_post_id, | |
| 436 | - * source_campaign_id | campaign label)` rule the importer keys on — for every | |
| 437 | - * pre-v6 one-time Charitable row. Chunked so a large migrated table does not | |
| 438 | - * exhaust memory during the upgrade; idempotent (touches only empty keys). | |
| 439 | - * | |
| 440 | - * @return void | |
| 441 | - * @since 1.5.1 | |
| 442 | - */ | |
| 443 | - private function backfill_import_provenance() { | |
| 444 | - global $wpdb; | |
| 445 | - $table = $this->get_tablename(); | |
| 446 | - | |
| 447 | - do { | |
| 448 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time chunked backfill of a newly added column; not cacheable. | |
| 449 | - $rows = $wpdb->get_results( | |
| 450 | - $wpdb->prepare( | |
| 451 | - 'SELECT id, donation_data FROM %i WHERE import_source = %s AND donation_type != %s AND import_provenance = %s LIMIT 500', | |
| 452 | - $table, | |
| 453 | - 'charitable', | |
| 454 | - 'recurring', | |
| 455 | - '' | |
| 456 | - ), | |
| 457 | - ARRAY_A | |
| 458 | - ); | |
| 459 | - | |
| 460 | - if ( empty( $rows ) || ! is_array( $rows ) ) { | |
| 461 | - break; | |
| 462 | - } | |
| 463 | - | |
| 464 | - $fetched = count( $rows ); | |
| 465 | - | |
| 466 | - foreach ( $rows as $row ) { | |
| 467 | - $data = json_decode( (string) ( $row['donation_data'] ?? '' ), true ); | |
| 468 | - $c = is_array( $data ) && isset( $data['charitable'] ) && is_array( $data['charitable'] ) ? $data['charitable'] : []; | |
| 469 | - $post = isset( $c['donation_post_id'] ) ? absint( $c['donation_post_id'] ) : 0; | |
| 470 | - | |
| 471 | - // A row with no resolvable donation post can never be dedupe-matched | |
| 472 | - // or rolled back; leave its key empty (it is already un-reversible) | |
| 473 | - // rather than fabricate a colliding "0:…" key. | |
| 474 | - if ( $post <= 0 ) { | |
| 475 | - $key = ''; | |
| 476 | - } else { | |
| 477 | - $campaign = isset( $c['source_campaign_id'] ) ? absint( $c['source_campaign_id'] ) : 0; | |
| 478 | - // DB-path rows carry `campaign_name`; CSV-path rows carry | |
| 479 | - // `campaign_title`. Either serves as the blank-id fallback label. | |
| 480 | - $label = ''; | |
| 481 | - if ( isset( $c['campaign_title'] ) && is_scalar( $c['campaign_title'] ) ) { | |
| 482 | - $label = (string) $c['campaign_title']; | |
| 483 | - } elseif ( isset( $c['campaign_name'] ) && is_scalar( $c['campaign_name'] ) ) { | |
| 484 | - $label = (string) $c['campaign_name']; | |
| 485 | - } | |
| 486 | - $key = self::build_provenance_key( $post, $campaign, $label ); | |
| 487 | - } | |
| 488 | - | |
| 489 | - if ( '' === $key ) { | |
| 490 | - // Nothing to store, but stamp a sentinel so the WHERE clause | |
| 491 | - // stops selecting this row and the loop terminates. | |
| 492 | - $key = '-'; | |
| 493 | - } | |
| 494 | - | |
| 495 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill update; not cacheable. | |
| 496 | - $wpdb->update( $table, [ 'import_provenance' => $key ], [ 'id' => absint( $row['id'] ) ] ); | |
| 497 | - } | |
| 498 | - } while ( 500 === $fetched ); | |
| 499 | - } | |
| 500 | - | |
| 501 | - /** | |
| 502 | - * Build the indexed dedupe key for a Charitable donation row. | |
| 503 | - * | |
| 504 | - * `"<donation_post_id>:<token>"`, where the token is the numeric campaign id | |
| 505 | - * when present, otherwise a short hash of the campaign label (so the | |
| 506 | - * per-campaign rows of a multi-campaign donation whose export left the | |
| 507 | - * Campaign ID cell blank stay distinct instead of collapsing to "<post>:0"), | |
| 508 | - * otherwise "0". Static so both the importer (Provenance_Dedupe) and the v6 | |
| 509 | - * backfill derive identical keys. | |
| 510 | - * | |
| 511 | - * @param int $donation_post_id Charitable donation post ID. | |
| 512 | - * @param int $source_campaign_id Charitable campaign ID (0 when absent). | |
| 513 | - * @param string $campaign_label Campaign title/name fallback (optional). | |
| 514 | - * @return string | |
| 515 | - * @since 1.5.1 | |
| 516 | - */ | |
| 517 | - public static function build_provenance_key( $donation_post_id, $source_campaign_id, $campaign_label = '' ) { | |
| 518 | - $post = absint( $donation_post_id ); | |
| 519 | - $cid = absint( $source_campaign_id ); | |
| 520 | - $label = trim( (string) $campaign_label ); | |
| 521 | - | |
| 522 | - if ( $cid > 0 ) { | |
| 523 | - $token = (string) $cid; | |
| 524 | - } elseif ( '' !== $label ) { | |
| 525 | - $token = 't:' . substr( md5( strtolower( $label ) ), 0, 12 ); | |
| 526 | - } else { | |
| 527 | - $token = '0'; | |
| 528 | - } | |
| 529 | - | |
| 530 | - return $post . ':' . $token; | |
| 531 | - } | |
| 532 | - | |
| 533 | - /** | |
| 534 | 291 | * Add a new donation record. |
| 535 | 292 | * |
| 536 | 293 | * @param array<mixed> $data Donation data to insert. |
| 537 | 294 | * @return int|false The donation ID on success, false on error. |
| @@ -562,48 +319,16 @@ | ||
| 562 | 319 | $donation_id = absint( $result ); |
| 563 | 320 | $donation = self::get( $donation_id ); |
| 564 | 321 | $donation = is_array( $donation ) ? $donation : []; |
| 565 | 322 | |
| 566 | - // Curated payload (internal/gateway-only columns omitted; donor | |
| 567 | - // identity included, see the note in get_integration_payload()) | |
| 568 | - // shared by every hook below. | |
| 569 | - $payload = self::get_integration_payload( $donation ); | |
| 570 | - | |
| 571 | 323 | /** |
| 572 | 324 | * Fires when a new donation record is created. |
| 573 | 325 | * |
| 574 | 326 | * @param int $donation_id Newly created donation ID. |
| 575 | - * @param array<mixed> $donation Curated donation payload. | |
| 327 | + * @param array<mixed> $donation Complete donation record. | |
| 576 | 328 | * @since 1.1.0 |
| 577 | 329 | */ |
| 578 | - do_action( 'suredonation_donation_created', $donation_id, $payload ); | |
| 579 | - | |
| 580 | - /** | |
| 581 | - * Fires when a new donation record is created. | |
| 582 | - * | |
| 583 | - * Mirrors `suredonation_donation_created`; the OttoKit (formerly | |
| 584 | - * SureTriggers) "New Donation" trigger listens on this hook name. | |
| 585 | - * | |
| 586 | - * @param int $donation_id Newly created donation ID. | |
| 587 | - * @param array<mixed> $donation Curated donation payload. | |
| 588 | - * @since 1.2.0 | |
| 589 | - */ | |
| 590 | - do_action( 'suredonation_new_donation', $donation_id, $payload ); | |
| 591 | - | |
| 592 | - // Some donations are created already-completed rather than | |
| 593 | - // transitioning through update() — recurring renewals and | |
| 594 | - // admin-recorded paid donations. Fire the completion event here | |
| 595 | - // too so integration hooks still see them. | |
| 596 | - if ( 'completed' === ( $data['payment_status'] ?? '' ) ) { | |
| 597 | - /** | |
| 598 | - * Fires when a donation payment is completed. | |
| 599 | - * | |
| 600 | - * @param int $donation_id Donation ID. | |
| 601 | - * @param array<mixed> $donation Curated donation payload after insertion. | |
| 602 | - * @since 1.2.0 | |
| 603 | - */ | |
| 604 | - do_action( 'suredonation_donation_completed', $donation_id, $payload ); | |
| 605 | - } | |
| 330 | + do_action( 'suredonation_donation_created', $donation_id, $donation ); | |
| 606 | 331 | } |
| 607 | 332 | } |
| 608 | 333 | |
| 609 | 334 | return $result; |
| @@ -621,17 +346,15 @@ | ||
| 621 | 346 | if ( empty( $donation_id ) ) { |
| 622 | 347 | return false; |
| 623 | 348 | } |
| 624 | 349 | |
| 625 | - // Capture the current status and refunded amount before the write so | |
| 626 | - // integration hooks (e.g. OttoKit) can react to the transition and to | |
| 627 | - // refund events, not just the resulting values. | |
| 628 | - $old_status = ''; | |
| 629 | - $old_refunded = 0.0; | |
| 630 | - if ( isset( $data['payment_status'] ) || isset( $data['refunded_amount'] ) ) { | |
| 631 | - $existing = self::get( absint( $donation_id ) ); | |
| 632 | - $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : ''; | |
| 633 | - $old_refunded = is_array( $existing ) ? Helper::get_float_value( $existing['refunded_amount'] ?? 0 ) : 0.0; | |
| 350 | + // Capture the current status before the write so integration hooks | |
| 351 | + // (e.g. OttoKit) can react to the actual status transition, not just | |
| 352 | + // the resulting value. | |
| 353 | + $old_status = ''; | |
| 354 | + if ( isset( $data['payment_status'] ) ) { | |
| 355 | + $existing = self::get( absint( $donation_id ) ); | |
| 356 | + $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : ''; | |
| 634 | 357 | } |
| 635 | 358 | |
| 636 | 359 | // Set updated_at. |
| 637 | 360 | $data['updated_at'] = current_time( 'mysql' ); |
| @@ -641,22 +364,22 @@ | ||
| 641 | 364 | // Status/amount changes (e.g. a webhook completing a pending donation) |
| 642 | 365 | // affect the cached stats and donor lists. |
| 643 | 366 | if ( $updated ) { |
| 644 | 367 | $donation = self::get( absint( $donation_id ) ); |
| 645 | - $donation = is_array( $donation ) ? $donation : []; | |
| 646 | 368 | if ( ! empty( $donation['campaign_id'] ) ) { |
| 647 | 369 | Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) ); |
| 648 | 370 | } |
| 649 | 371 | |
| 650 | - // Curated payload (internal/gateway-only columns omitted; donor | |
| 651 | - // identity included, see the note in get_integration_payload()) | |
| 652 | - // shared by every hook below. | |
| 653 | - $payload = self::get_integration_payload( $donation ); | |
| 654 | - | |
| 372 | + // Notify integration hooks about a genuine status transition. | |
| 373 | + // Fired from update() — the single choke point every status write | |
| 374 | + // passes through (update_status() delegates here, as do the payment | |
| 375 | + // frontends and webhooks) — so all transitions are caught. | |
| 655 | 376 | if ( isset( $data['payment_status'] ) ) { |
| 656 | 377 | $new_status = Helper::get_string_value( $data['payment_status'] ); |
| 657 | 378 | |
| 658 | 379 | if ( $new_status !== $old_status ) { |
| 380 | + $donation = is_array( $donation ) ? $donation : []; | |
| 381 | + | |
| 659 | 382 | /** |
| 660 | 383 | * Fires when a donation's payment status changes. |
| 661 | 384 | * |
| 662 | 385 | * @param int $donation_id Donation ID. |
| @@ -661,51 +384,14 @@ | ||
| 661 | 384 | * |
| 662 | 385 | * @param int $donation_id Donation ID. |
| 663 | 386 | * @param string $new_status New payment status. |
| 664 | 387 | * @param string $old_status Previous payment status (empty string if unknown). |
| 665 | - * @param array<mixed> $donation Curated donation payload after the update. | |
| 388 | + * @param array<mixed> $donation Complete donation record after the update. | |
| 666 | 389 | * @since 1.1.0 |
| 667 | 390 | */ |
| 668 | - do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $payload ); | |
| 669 | - | |
| 670 | - // Fire the completion event for any genuine transition into | |
| 671 | - // 'completed' — including admin review states (suspicious, | |
| 672 | - // cancelled) — but never for refund reversals that restore | |
| 673 | - // the 'completed' status (refunded/partially_refunded -> | |
| 674 | - // completed), which would replay the completion automation. | |
| 675 | - if ( 'completed' === $new_status && ! in_array( $old_status, [ 'completed', 'refunded', 'partially_refunded' ], true ) ) { | |
| 676 | - /** | |
| 677 | - * Fires when a donation payment is completed. | |
| 678 | - * | |
| 679 | - * @param int $donation_id Donation ID. | |
| 680 | - * @param array<mixed> $donation Curated donation payload after the update. | |
| 681 | - * @since 1.2.0 | |
| 682 | - */ | |
| 683 | - do_action( 'suredonation_donation_completed', absint( $donation_id ), $payload ); | |
| 684 | - } | |
| 391 | + do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $donation ); | |
| 685 | 392 | } |
| 686 | 393 | } |
| 687 | - | |
| 688 | - // A rise in refunded_amount means a refund was processed. Keying off | |
| 689 | - // the amount (not the status string) catches repeat partial refunds | |
| 690 | - // that leave the status as partially_refunded, and excludes refund | |
| 691 | - // reversals where the amount drops. | |
| 692 | - if ( isset( $data['refunded_amount'] ) ) { | |
| 693 | - $new_refunded = Helper::get_float_value( $data['refunded_amount'] ); | |
| 694 | - | |
| 695 | - if ( $new_refunded - $old_refunded > 0.0001 ) { | |
| 696 | - /** | |
| 697 | - * Fires when a donation is refunded, fully or partially. | |
| 698 | - * | |
| 699 | - * @param int $donation_id Donation ID. | |
| 700 | - * @param float $refund_amount Amount refunded in this event. | |
| 701 | - * @param float $total_refunded Cumulative amount refunded to date. | |
| 702 | - * @param array<mixed> $donation Curated donation payload after the update. | |
| 703 | - * @since 1.2.0 | |
| 704 | - */ | |
| 705 | - do_action( 'suredonation_donation_refunded', absint( $donation_id ), $new_refunded - $old_refunded, $new_refunded, $payload ); | |
| 706 | - } | |
| 707 | - } | |
| 708 | 394 | } |
| 709 | 395 | |
| 710 | 396 | return $updated; |
| 711 | 397 | } |
| @@ -710,82 +396,8 @@ | ||
| 710 | 396 | return $updated; |
| 711 | 397 | } |
| 712 | 398 | |
| 713 | 399 | /** |
| 714 | - * Build a curated donation payload for integration hooks. | |
| 715 | - * | |
| 716 | - * Trims the raw database row to the fields advertised in the OttoKit embed | |
| 717 | - * `sample_response`, omitting internal and gateway-only columns that must not | |
| 718 | - * leave the site (ip_address, user_agent, referer_url, the admin `log`, the | |
| 719 | - * gateway `customer_id`, and the full `donation_data` submission). Monetary | |
| 720 | - * values are cast to float to match the sample the automation builder maps | |
| 721 | - * against (the raw column is a DECIMAL string). Shared by every `do_action` | |
| 722 | - * in add()/update() so no listener — OttoKit or otherwise — receives the raw | |
| 723 | - * row. | |
| 724 | - * | |
| 725 | - * Anonymous donations carry their real donor identity here. The anonymous | |
| 726 | - * checkbox is a display-only flag — the data is stored and processed as | |
| 727 | - * usual, and only the public donor wall / recent donations / top donors mask | |
| 728 | - * it. Automations that need to treat anonymous donors differently branch on | |
| 729 | - * the `is_anonymous` field in this payload; blanking the identity instead | |
| 730 | - * would silently break receipting and CRM sync for those donations. | |
| 731 | - * | |
| 732 | - * @param array<string,mixed> $donation Raw donation record from self::get(). | |
| 733 | - * @return array<string,mixed> Curated, integration-safe payload. | |
| 734 | - * @since 1.2.0 | |
| 735 | - */ | |
| 736 | - public static function get_integration_payload( $donation ) { | |
| 737 | - if ( ! is_array( $donation ) ) { | |
| 738 | - return []; | |
| 739 | - } | |
| 740 | - | |
| 741 | - $is_anonymous = ! empty( $donation['is_anonymous'] ); | |
| 742 | - | |
| 743 | - $payload = [ | |
| 744 | - 'id' => isset( $donation['id'] ) ? absint( Helper::get_string_value( $donation['id'] ) ) : 0, | |
| 745 | - 'campaign_id' => isset( $donation['campaign_id'] ) ? absint( Helper::get_string_value( $donation['campaign_id'] ) ) : 0, | |
| 746 | - 'form_id' => isset( $donation['form_id'] ) ? absint( Helper::get_string_value( $donation['form_id'] ) ) : 0, | |
| 747 | - 'donor_id' => isset( $donation['donor_id'] ) ? absint( Helper::get_string_value( $donation['donor_id'] ) ) : 0, | |
| 748 | - 'donor_name' => Helper::get_string_value( $donation['donor_name'] ?? '' ), | |
| 749 | - 'donor_email' => Helper::get_string_value( $donation['donor_email'] ?? '' ), | |
| 750 | - 'donor_phone' => Helper::get_string_value( $donation['donor_phone'] ?? '' ), | |
| 751 | - 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ), | |
| 752 | - 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ), | |
| 753 | - 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ), | |
| 754 | - 'currency' => Helper::get_string_value( $donation['currency'] ?? '' ), | |
| 755 | - 'gateway' => Helper::get_string_value( $donation['gateway'] ?? '' ), | |
| 756 | - 'payment_status' => Helper::get_string_value( $donation['payment_status'] ?? '' ), | |
| 757 | - 'payment_mode' => Helper::get_string_value( $donation['payment_mode'] ?? '' ), | |
| 758 | - 'donation_type' => Helper::get_string_value( $donation['donation_type'] ?? '' ), | |
| 759 | - 'transaction_id' => Helper::get_string_value( $donation['transaction_id'] ?? '' ), | |
| 760 | - 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ), | |
| 761 | - 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ), | |
| 762 | - 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ), | |
| 763 | - 'donor_comment_status' => Helper::get_string_value( $donation['donor_comment_status'] ?? '' ), | |
| 764 | - 'is_anonymous' => $is_anonymous, | |
| 765 | - 'created_at' => Helper::get_string_value( $donation['created_at'] ?? '' ), | |
| 766 | - 'updated_at' => Helper::get_string_value( $donation['updated_at'] ?? '' ), | |
| 767 | - ]; | |
| 768 | - | |
| 769 | - /** | |
| 770 | - * Filter the curated donation payload passed to every integration hook. | |
| 771 | - * | |
| 772 | - * The payload carries the donor's real identity even for anonymous | |
| 773 | - * donations, because the anonymous checkbox only masks public donor | |
| 774 | - * lists — automations still need a usable record, and they can branch on | |
| 775 | - * the `is_anonymous` field. A site with a stricter policy (for example an | |
| 776 | - * automation that posts donor names somewhere public) can use this filter | |
| 777 | - * to blank or drop fields before they reach OttoKit or any third-party | |
| 778 | - * listener. | |
| 779 | - * | |
| 780 | - * @param array<string,mixed> $payload Curated payload. | |
| 781 | - * @param array<string,mixed> $donation Raw donation record. | |
| 782 | - * @since 1.4.0 | |
| 783 | - */ | |
| 784 | - return apply_filters( 'suredonation_integration_payload', $payload, $donation ); | |
| 785 | - } | |
| 786 | - | |
| 787 | - /** | |
| 788 | 400 | * Get a single donation by ID. |
| 789 | 401 | * |
| 790 | 402 | * @param int $donation_id Donation ID. |
| 791 | 403 | * @return array<mixed>|null Donation data or null if not found. |
| @@ -905,14 +517,8 @@ | ||
| 905 | 517 | // Build query based on filters. |
| 906 | 518 | // Note: Renewal records (donation_type = 'renewal') are intentionally included in the listing. |
| 907 | 519 | // They are shown alongside parent subscriptions so admins can see all transaction activity. |
| 908 | 520 | // Renewals are also accessible from the parent donation's subscription detail billing history. |
| 909 | - // With no status filter, abandoned rows are left out: they are kept as | |
| 910 | - // funnel data (a campaign with 40 starts against 3 completions has | |
| 911 | - // learned something real) but a donor who walked away from the gateway is | |
| 912 | - // not a transaction an admin needs in their default view. Asking for the | |
| 913 | - // status explicitly still returns them, and count_admin_list() mirrors | |
| 914 | - // this or the pagination totals disagree with the rows. | |
| 915 | 521 | $has_status = 'all' !== $status; |
| 916 | 522 | $has_campaign = $campaign_id > 0; |
| 917 | 523 | $has_search = ! empty( $search ); |
| 918 | 524 | $is_asc = 'ASC' === $order; |
| @@ -1014,9 +620,9 @@ | ||
| 1014 | 620 | $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%'; |
| 1015 | 621 | $results = $is_asc |
| 1016 | 622 | ? $wpdb->get_results( |
| 1017 | 623 | $wpdb->prepare( |
| 1018 | - 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d', | |
| 624 | + 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d', | |
| 1019 | 625 | $table, |
| 1020 | 626 | absint( $campaign_id ), |
| 1021 | 627 | $search_term, |
| 1022 | 628 | $search_term, |
| @@ -1028,9 +634,9 @@ | ||
| 1028 | 634 | ARRAY_A |
| 1029 | 635 | ) |
| 1030 | 636 | : $wpdb->get_results( |
| 1031 | 637 | $wpdb->prepare( |
| 1032 | - 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d', | |
| 638 | + 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d', | |
| 1033 | 639 | $table, |
| 1034 | 640 | absint( $campaign_id ), |
| 1035 | 641 | $search_term, |
| 1036 | 642 | $search_term, |
| @@ -1068,9 +674,9 @@ | ||
| 1068 | 674 | } elseif ( $has_campaign ) { |
| 1069 | 675 | $results = $is_asc |
| 1070 | 676 | ? $wpdb->get_results( |
| 1071 | 677 | $wpdb->prepare( |
| 1072 | - 'SELECT * FROM %i WHERE campaign_id = %d AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d', | |
| 678 | + 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d', | |
| 1073 | 679 | $table, |
| 1074 | 680 | absint( $campaign_id ), |
| 1075 | 681 | $orderby, |
| 1076 | 682 | absint( $offset ), |
| @@ -1079,9 +685,9 @@ | ||
| 1079 | 685 | ARRAY_A |
| 1080 | 686 | ) |
| 1081 | 687 | : $wpdb->get_results( |
| 1082 | 688 | $wpdb->prepare( |
| 1083 | - 'SELECT * FROM %i WHERE campaign_id = %d AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d', | |
| 689 | + 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d', | |
| 1084 | 690 | $table, |
| 1085 | 691 | absint( $campaign_id ), |
| 1086 | 692 | $orderby, |
| 1087 | 693 | absint( $offset ), |
| @@ -1093,9 +699,9 @@ | ||
| 1093 | 699 | $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%'; |
| 1094 | 700 | $results = $is_asc |
| 1095 | 701 | ? $wpdb->get_results( |
| 1096 | 702 | $wpdb->prepare( |
| 1097 | - 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d', | |
| 703 | + 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d', | |
| 1098 | 704 | $table, |
| 1099 | 705 | $search_term, |
| 1100 | 706 | $search_term, |
| 1101 | 707 | $search_term, |
| @@ -1106,9 +712,9 @@ | ||
| 1106 | 712 | ARRAY_A |
| 1107 | 713 | ) |
| 1108 | 714 | : $wpdb->get_results( |
| 1109 | 715 | $wpdb->prepare( |
| 1110 | - 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) AND payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d', | |
| 716 | + 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d', | |
| 1111 | 717 | $table, |
| 1112 | 718 | $search_term, |
| 1113 | 719 | $search_term, |
| 1114 | 720 | $search_term, |
| @@ -1121,9 +727,9 @@ | ||
| 1121 | 727 | } else { |
| 1122 | 728 | $results = $is_asc |
| 1123 | 729 | ? $wpdb->get_results( |
| 1124 | 730 | $wpdb->prepare( |
| 1125 | - 'SELECT * FROM %i WHERE payment_status != \'abandoned\' ORDER BY %i ASC LIMIT %d, %d', | |
| 731 | + 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d', | |
| 1126 | 732 | $table, |
| 1127 | 733 | $orderby, |
| 1128 | 734 | absint( $offset ), |
| 1129 | 735 | absint( $limit ) |
| @@ -1131,9 +737,9 @@ | ||
| 1131 | 737 | ARRAY_A |
| 1132 | 738 | ) |
| 1133 | 739 | : $wpdb->get_results( |
| 1134 | 740 | $wpdb->prepare( |
| 1135 | - 'SELECT * FROM %i WHERE payment_status != \'abandoned\' ORDER BY %i DESC LIMIT %d, %d', | |
| 741 | + 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d', | |
| 1136 | 742 | $table, |
| 1137 | 743 | $orderby, |
| 1138 | 744 | absint( $offset ), |
| 1139 | 745 | absint( $limit ) |
| @@ -1151,147 +757,8 @@ | ||
| 1151 | 757 | return array_map( [ $instance, 'decode_by_datatype' ], $results ); |
| 1152 | 758 | } |
| 1153 | 759 | |
| 1154 | 760 | /** |
| 1155 | - * Build the WHERE clause + prepare-args for an export query. | |
| 1156 | - * | |
| 1157 | - * Always constrains to one-time donations (subscription_id = '' AND | |
| 1158 | - * parent_subscription_id = 0) so recurring/renewal rows never leak into the | |
| 1159 | - * free export — recurring export is Pro (see the Import & Export spec, #237). | |
| 1160 | - * Optional filters: status, campaign_id, payment_mode, gateway, and a | |
| 1161 | - * created_at date range (after / before). | |
| 1162 | - * | |
| 1163 | - * @param array<string, mixed> $filters Filter map. | |
| 1164 | - * @param array<int, mixed> $args Prepare-args, populated by reference in placeholder order. | |
| 1165 | - * @return string WHERE clause (without the "WHERE" keyword); placeholders only, no interpolated values. | |
| 1166 | - * @since 1.3.0 | |
| 1167 | - */ | |
| 1168 | - private static function build_export_where( $filters, &$args ) { | |
| 1169 | - $conditions = [ '1=1' ]; | |
| 1170 | - | |
| 1171 | - /** | |
| 1172 | - * Whether the donations export is restricted to one-time donations. | |
| 1173 | - * | |
| 1174 | - * True by default so recurring/renewal rows never leak into the free | |
| 1175 | - * export; Pro returns false to include subscriptions and renewals. | |
| 1176 | - * | |
| 1177 | - * @param bool $one_time_only Whether to restrict to one-time donations. | |
| 1178 | - */ | |
| 1179 | - if ( apply_filters( 'suredonation_export_one_time_only', true ) ) { | |
| 1180 | - $conditions[] = 'subscription_id = %s'; | |
| 1181 | - $conditions[] = 'parent_subscription_id = %d'; | |
| 1182 | - $args[] = ''; | |
| 1183 | - $args[] = 0; | |
| 1184 | - } | |
| 1185 | - | |
| 1186 | - $status = sanitize_text_field( Helper::get_string_value( $filters['status'] ?? '' ) ); | |
| 1187 | - if ( '' !== $status && 'all' !== $status ) { | |
| 1188 | - $conditions[] = 'payment_status = %s'; | |
| 1189 | - $args[] = $status; | |
| 1190 | - } | |
| 1191 | - | |
| 1192 | - $campaign_id = absint( Helper::get_string_value( $filters['campaign_id'] ?? 0 ) ); | |
| 1193 | - if ( $campaign_id > 0 ) { | |
| 1194 | - $conditions[] = 'campaign_id = %d'; | |
| 1195 | - $args[] = $campaign_id; | |
| 1196 | - } | |
| 1197 | - | |
| 1198 | - $payment_mode = sanitize_text_field( Helper::get_string_value( $filters['payment_mode'] ?? '' ) ); | |
| 1199 | - if ( '' !== $payment_mode ) { | |
| 1200 | - $conditions[] = 'payment_mode = %s'; | |
| 1201 | - $args[] = $payment_mode; | |
| 1202 | - } | |
| 1203 | - | |
| 1204 | - $gateway = sanitize_text_field( Helper::get_string_value( $filters['gateway'] ?? '' ) ); | |
| 1205 | - if ( '' !== $gateway ) { | |
| 1206 | - $conditions[] = 'gateway = %s'; | |
| 1207 | - $args[] = $gateway; | |
| 1208 | - } | |
| 1209 | - | |
| 1210 | - $after = sanitize_text_field( Helper::get_string_value( $filters['after'] ?? '' ) ); | |
| 1211 | - if ( '' !== $after ) { | |
| 1212 | - $conditions[] = 'created_at >= %s'; | |
| 1213 | - $args[] = $after; | |
| 1214 | - } | |
| 1215 | - | |
| 1216 | - $before = sanitize_text_field( Helper::get_string_value( $filters['before'] ?? '' ) ); | |
| 1217 | - if ( '' !== $before ) { | |
| 1218 | - // A date-only `before` (Y-m-d) coerces to 00:00:00, which would | |
| 1219 | - // silently drop donations made later that same day. Normalize to | |
| 1220 | - // end-of-day so the whole end date is inclusive; full datetimes | |
| 1221 | - // are left untouched. | |
| 1222 | - if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $before ) ) { | |
| 1223 | - $before .= ' 23:59:59'; | |
| 1224 | - } | |
| 1225 | - $conditions[] = 'created_at <= %s'; | |
| 1226 | - $args[] = $before; | |
| 1227 | - } | |
| 1228 | - | |
| 1229 | - return implode( ' AND ', $conditions ); | |
| 1230 | - } | |
| 1231 | - | |
| 1232 | - /** | |
| 1233 | - * Count one-time donations matching the export filters. | |
| 1234 | - * | |
| 1235 | - * @param array<string, mixed> $filters Filter map (see build_export_where()). | |
| 1236 | - * @return int Matching row count. | |
| 1237 | - * @since 1.3.0 | |
| 1238 | - */ | |
| 1239 | - public static function count_for_export( $filters = [] ) { | |
| 1240 | - $instance = self::get_instance(); | |
| 1241 | - global $wpdb; | |
| 1242 | - $table = $instance->get_tablename(); | |
| 1243 | - | |
| 1244 | - $args = []; | |
| 1245 | - $where = self::build_export_where( $filters, $args ); | |
| 1246 | - | |
| 1247 | - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export count over live data. | |
| 1248 | - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args. | |
| 1249 | - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( [ $table ], $args ) ) ); | |
| 1250 | - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1251 | - | |
| 1252 | - return is_numeric( $count ) ? (int) $count : 0; | |
| 1253 | - } | |
| 1254 | - | |
| 1255 | - /** | |
| 1256 | - * Fetch one-time donations for export, decoded. | |
| 1257 | - * | |
| 1258 | - * @param array<string, mixed> $filters Filter map (see build_export_where()). | |
| 1259 | - * @param int $limit Max rows to return (0 = no limit). | |
| 1260 | - * @param int $offset Offset for pagination. | |
| 1261 | - * @return array<int, array<string, mixed>> Decoded donation rows. | |
| 1262 | - * @since 1.3.0 | |
| 1263 | - */ | |
| 1264 | - public static function get_for_export( $filters = [], $limit = 0, $offset = 0 ) { | |
| 1265 | - $instance = self::get_instance(); | |
| 1266 | - global $wpdb; | |
| 1267 | - $table = $instance->get_tablename(); | |
| 1268 | - | |
| 1269 | - $args = []; | |
| 1270 | - $where = self::build_export_where( $filters, $args ); | |
| 1271 | - | |
| 1272 | - $sql = "SELECT * FROM %i WHERE {$where} ORDER BY created_at DESC"; | |
| 1273 | - $prepare_args = array_merge( [ $table ], $args ); | |
| 1274 | - | |
| 1275 | - if ( $limit > 0 ) { | |
| 1276 | - $sql .= ' LIMIT %d, %d'; | |
| 1277 | - $prepare_args[] = absint( $offset ); | |
| 1278 | - $prepare_args[] = absint( $limit ); | |
| 1279 | - } | |
| 1280 | - | |
| 1281 | - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export query over live data. | |
| 1282 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $sql is assembled only from static placeholder fragments; every value is passed through prepare args. | |
| 1283 | - $results = $wpdb->get_results( $wpdb->prepare( $sql, $prepare_args ), ARRAY_A ); | |
| 1284 | - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1285 | - | |
| 1286 | - if ( ! $results || ! is_array( $results ) ) { | |
| 1287 | - return []; | |
| 1288 | - } | |
| 1289 | - | |
| 1290 | - return array_map( [ $instance, 'decode_by_datatype' ], $results ); | |
| 1291 | - } | |
| 1292 | - | |
| 1293 | - /** | |
| 1294 | 761 | * Get donations by status with pagination. |
| 1295 | 762 | * |
| 1296 | 763 | * @param string $status Payment status. |
| 1297 | 764 | * @param int $limit Number of records to return. |
| @@ -1431,15 +898,13 @@ | ||
| 1431 | 898 | |
| 1432 | 899 | /** |
| 1433 | 900 | * Get donations by donor email. |
| 1434 | 901 | * |
| 1435 | - * @param string $email Donor email. | |
| 1436 | - * @param int $limit Max rows to return; 0 (default) returns all rows. | |
| 1437 | - * @param int $offset Row offset, applied only when $limit > 0. | |
| 902 | + * @param string $email Donor email. | |
| 1438 | 903 | * @return array<mixed> Array of donations. |
| 1439 | 904 | * @since 0.0.1 |
| 1440 | 905 | */ |
| 1441 | - public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) { | |
| 906 | + public static function get_by_donor_email( $email ) { | |
| 1442 | 907 | if ( empty( $email ) ) { |
| 1443 | 908 | return []; |
| 1444 | 909 | } |
| 1445 | 910 | |
| @@ -1445,35 +910,18 @@ | ||
| 1445 | 910 | |
| 1446 | 911 | $instance = self::get_instance(); |
| 1447 | 912 | global $wpdb; |
| 1448 | 913 | |
| 1449 | - $limit = max( 0, (int) $limit ); | |
| 1450 | - $offset = max( 0, (int) $offset ); | |
| 914 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 915 | + $results = $wpdb->get_results( | |
| 916 | + $wpdb->prepare( | |
| 917 | + 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC', | |
| 918 | + $instance->get_tablename(), | |
| 919 | + sanitize_email( $email ) | |
| 920 | + ), | |
| 921 | + ARRAY_A | |
| 922 | + ); | |
| 1451 | 923 | |
| 1452 | - if ( $limit > 0 ) { | |
| 1453 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1454 | - $results = $wpdb->get_results( | |
| 1455 | - $wpdb->prepare( | |
| 1456 | - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d', | |
| 1457 | - $instance->get_tablename(), | |
| 1458 | - sanitize_email( $email ), | |
| 1459 | - $limit, | |
| 1460 | - $offset | |
| 1461 | - ), | |
| 1462 | - ARRAY_A | |
| 1463 | - ); | |
| 1464 | - } else { | |
| 1465 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1466 | - $results = $wpdb->get_results( | |
| 1467 | - $wpdb->prepare( | |
| 1468 | - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC', | |
| 1469 | - $instance->get_tablename(), | |
| 1470 | - sanitize_email( $email ) | |
| 1471 | - ), | |
| 1472 | - ARRAY_A | |
| 1473 | - ); | |
| 1474 | - } | |
| 1475 | - | |
| 1476 | 924 | if ( ! $results || ! is_array( $results ) ) { |
| 1477 | 925 | return []; |
| 1478 | 926 | } |
| 1479 | 927 | |
| @@ -1512,50 +960,8 @@ | ||
| 1512 | 960 | return $instance->decode_by_datatype( $result ); |
| 1513 | 961 | } |
| 1514 | 962 | |
| 1515 | 963 | /** |
| 1516 | - * Get donation by gateway subscription ID. | |
| 1517 | - * | |
| 1518 | - * Recurring handling lives in Pro, but the table (and its | |
| 1519 | - * `idx_subscription` index) belongs here, so free-side code that only needs | |
| 1520 | - * to resolve a row — such as the PayPal webhook listener recording why a | |
| 1521 | - * delivery was rejected — can look one up without depending on Pro. | |
| 1522 | - * | |
| 1523 | - * Renewals carry the same `subscription_id` as the subscription they belong | |
| 1524 | - * to, so the column is deliberately not unique. The parent row (the one with | |
| 1525 | - * no `parent_subscription_id`) is preferred and the oldest id breaks any | |
| 1526 | - * remaining tie, so the result does not depend on the query plan. | |
| 1527 | - * | |
| 1528 | - * @param string $subscription_id Gateway subscription ID. | |
| 1529 | - * @return array<string, mixed>|null Donation data or null if not found. | |
| 1530 | - * @since 1.4.0 | |
| 1531 | - */ | |
| 1532 | - public static function get_by_subscription_id( $subscription_id ) { | |
| 1533 | - if ( empty( $subscription_id ) ) { | |
| 1534 | - return null; | |
| 1535 | - } | |
| 1536 | - | |
| 1537 | - $instance = self::get_instance(); | |
| 1538 | - global $wpdb; | |
| 1539 | - | |
| 1540 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1541 | - $result = $wpdb->get_row( | |
| 1542 | - $wpdb->prepare( | |
| 1543 | - 'SELECT * FROM %i WHERE subscription_id = %s ORDER BY parent_subscription_id ASC, id ASC LIMIT 1', | |
| 1544 | - $instance->get_tablename(), | |
| 1545 | - sanitize_text_field( $subscription_id ) | |
| 1546 | - ), | |
| 1547 | - ARRAY_A | |
| 1548 | - ); | |
| 1549 | - | |
| 1550 | - if ( ! $result ) { | |
| 1551 | - return null; | |
| 1552 | - } | |
| 1553 | - | |
| 1554 | - return $instance->decode_by_datatype( $result ); | |
| 1555 | - } | |
| 1556 | - | |
| 1557 | - /** | |
| 1558 | 964 | * Get total donations count (no filters). |
| 1559 | 965 | * |
| 1560 | 966 | * @return int Total count. |
| 1561 | 967 | * @since 0.0.1 |
| @@ -1598,52 +1004,8 @@ | ||
| 1598 | 1004 | return is_numeric( $count ) ? (int) $count : 0; |
| 1599 | 1005 | } |
| 1600 | 1006 | |
| 1601 | 1007 | /** |
| 1602 | - * Get the count of completed, live-mode donations. | |
| 1603 | - * | |
| 1604 | - * Used to gate the review admin notice: a completed live donation is the | |
| 1605 | - * signal that the site has taken a genuine (non-test) donation. | |
| 1606 | - * | |
| 1607 | - * @param string $gateway Optional gateway to scope the count to, e.g. 'paypal'. | |
| 1608 | - * Empty counts every gateway. | |
| 1609 | - * @return int Count of completed live donations. | |
| 1610 | - * @since 1.2.0 | |
| 1611 | - * @since 1.5.1 Optionally scoped to one gateway. | |
| 1612 | - */ | |
| 1613 | - public static function count_live_completed( $gateway = '' ) { | |
| 1614 | - $instance = self::get_instance(); | |
| 1615 | - global $wpdb; | |
| 1616 | - | |
| 1617 | - if ( '' !== $gateway ) { | |
| 1618 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1619 | - $count = $wpdb->get_var( | |
| 1620 | - $wpdb->prepare( | |
| 1621 | - 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s AND gateway = %s', | |
| 1622 | - $instance->get_tablename(), | |
| 1623 | - 'completed', | |
| 1624 | - 'live', | |
| 1625 | - $gateway | |
| 1626 | - ) | |
| 1627 | - ); | |
| 1628 | - | |
| 1629 | - return is_numeric( $count ) ? (int) $count : 0; | |
| 1630 | - } | |
| 1631 | - | |
| 1632 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1633 | - $count = $wpdb->get_var( | |
| 1634 | - $wpdb->prepare( | |
| 1635 | - 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s', | |
| 1636 | - $instance->get_tablename(), | |
| 1637 | - 'completed', | |
| 1638 | - 'live' | |
| 1639 | - ) | |
| 1640 | - ); | |
| 1641 | - | |
| 1642 | - return is_numeric( $count ) ? (int) $count : 0; | |
| 1643 | - } | |
| 1644 | - | |
| 1645 | - /** | |
| 1646 | 1008 | * Get total donations count by campaign. |
| 1647 | 1009 | * |
| 1648 | 1010 | * @param int $campaign_id Campaign ID. |
| 1649 | 1011 | * @return int Total count. |
| @@ -1705,53 +1067,58 @@ | ||
| 1705 | 1067 | return self::count_all(); |
| 1706 | 1068 | } |
| 1707 | 1069 | |
| 1708 | 1070 | /** |
| 1709 | - * Build the currency / payment-mode scope for a reporting query. | |
| 1071 | + * Get campaign statistics. | |
| 1710 | 1072 | * |
| 1711 | - * Amounts in different currencies cannot be summed into one figure, and test | |
| 1712 | - * donations must not be counted alongside live ones. Both filters are opt-in | |
| 1713 | - * so existing callers keep their behaviour; the abilities always pass them. | |
| 1714 | - * | |
| 1715 | - * @param string $currency Currency code ('' for no filter). | |
| 1716 | - * @param string $payment_mode 'test' or 'live' ('' for no filter). | |
| 1717 | - * @param array<mixed> $args Prepare args, appended to by reference. | |
| 1718 | - * @return string SQL fragment beginning with " AND ", or '' when unscoped. | |
| 1719 | - * @since 1.5.0 | |
| 1073 | + * @param int $campaign_id Campaign ID. | |
| 1074 | + * @return array<string,mixed> Campaign statistics. | |
| 1075 | + * @since 0.0.1 | |
| 1720 | 1076 | */ |
| 1721 | - private static function scope_fragment( $currency, $payment_mode, array &$args ) { | |
| 1722 | - $extra = ''; | |
| 1077 | + public static function get_campaign_stats( $campaign_id ) { | |
| 1078 | + $instance = self::get_instance(); | |
| 1079 | + global $wpdb; | |
| 1723 | 1080 | |
| 1724 | - $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : ''; | |
| 1725 | - if ( '' !== $currency ) { | |
| 1726 | - $extra .= ' AND currency = %s'; | |
| 1727 | - $args[] = $currency; | |
| 1728 | - } | |
| 1081 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1082 | + $stats = $wpdb->get_row( | |
| 1083 | + $wpdb->prepare( | |
| 1084 | + "SELECT | |
| 1085 | + COUNT(*) as donation_count, | |
| 1086 | + COALESCE(SUM(amount - refunded_amount), 0) as total_raised, | |
| 1087 | + COUNT(DISTINCT donor_email) as unique_donors, | |
| 1088 | + COALESCE(AVG(amount - refunded_amount), 0) as average_donation, | |
| 1089 | + COALESCE(MAX(amount - refunded_amount), 0) as largest_donation | |
| 1090 | + FROM %i | |
| 1091 | + WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded')", | |
| 1092 | + $instance->get_tablename(), | |
| 1093 | + absint( $campaign_id ) | |
| 1094 | + ), | |
| 1095 | + ARRAY_A | |
| 1096 | + ); | |
| 1729 | 1097 | |
| 1730 | - $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : ''; | |
| 1731 | - if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) { | |
| 1732 | - $extra .= ' AND payment_mode = %s'; | |
| 1733 | - $args[] = $payment_mode; | |
| 1734 | - } | |
| 1098 | + return $stats ? $stats : [ | |
| 1099 | + 'donation_count' => 0, | |
| 1100 | + 'total_raised' => 0, | |
| 1101 | + 'unique_donors' => 0, | |
| 1102 | + 'average_donation' => 0, | |
| 1103 | + 'largest_donation' => 0, | |
| 1104 | + ]; | |
| 1105 | + } | |
| 1735 | 1106 | |
| 1736 | - return $extra; | |
| 1737 | - } | |
| 1738 | 1107 | /** |
| 1739 | 1108 | * Get global dashboard statistics. |
| 1740 | 1109 | * |
| 1741 | - * @param string $currency Currency code to scope to ('' for no filter). | |
| 1742 | - * @param string $payment_mode 'test' or 'live' ('' for no filter). | |
| 1743 | 1110 | * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics. |
| 1744 | 1111 | * @since 0.0.1 |
| 1745 | 1112 | */ |
| 1746 | - public static function get_dashboard_stats( $currency = '', $payment_mode = '' ) { | |
| 1113 | + public static function get_dashboard_stats() { | |
| 1747 | 1114 | $instance = self::get_instance(); |
| 1748 | 1115 | global $wpdb; |
| 1749 | 1116 | |
| 1750 | - $args = [ $instance->get_tablename() ]; | |
| 1751 | - $extra = self::scope_fragment( $currency, $payment_mode, $args ); | |
| 1752 | - | |
| 1753 | - $sql = "SELECT | |
| 1117 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1118 | + $stats = $wpdb->get_row( | |
| 1119 | + $wpdb->prepare( | |
| 1120 | + "SELECT | |
| 1754 | 1121 | COUNT(*) as total_donations, |
| 1755 | 1122 | COALESCE(SUM(amount - refunded_amount), 0) as total_raised, |
| 1756 | 1123 | COUNT(DISTINCT donor_email) as unique_donors, |
| 1757 | 1124 | COALESCE(AVG(amount - refunded_amount), 0) as average_donation, |
| @@ -1756,14 +1123,14 @@ | ||
| 1756 | 1123 | COUNT(DISTINCT donor_email) as unique_donors, |
| 1757 | 1124 | COALESCE(AVG(amount - refunded_amount), 0) as average_donation, |
| 1758 | 1125 | COALESCE(MAX(amount - refunded_amount), 0) as largest_donation |
| 1759 | 1126 | FROM %i |
| 1760 | - WHERE payment_status IN ('completed', 'partially_refunded') | |
| 1761 | - {$extra}"; | |
| 1127 | + WHERE payment_status IN ('completed', 'partially_refunded')", | |
| 1128 | + $instance->get_tablename() | |
| 1129 | + ), | |
| 1130 | + ARRAY_A | |
| 1131 | + ); | |
| 1762 | 1132 | |
| 1763 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args. | |
| 1764 | - $stats = $wpdb->get_row( $wpdb->prepare( $sql, $args ), ARRAY_A ); | |
| 1765 | - | |
| 1766 | 1133 | return $stats ? $stats : [ |
| 1767 | 1134 | 'total_donations' => 0, |
| 1768 | 1135 | 'total_raised' => 0, |
| 1769 | 1136 | 'unique_donors' => 0, |
| @@ -1774,31 +1141,26 @@ | ||
| 1774 | 1141 | |
| 1775 | 1142 | /** |
| 1776 | 1143 | * Get recent donations globally (all campaigns). |
| 1777 | 1144 | * |
| 1778 | - * @param int $limit Number of donations to retrieve. | |
| 1779 | - * @param string $currency Currency code to scope to ('' for no filter). | |
| 1780 | - * @param string $payment_mode 'test' or 'live' ('' for no filter). | |
| 1145 | + * @param int $limit Number of donations to retrieve. | |
| 1781 | 1146 | * @return array<int, array<string, mixed>> Array of recent donations. |
| 1782 | 1147 | * @since 0.0.1 |
| 1783 | 1148 | */ |
| 1784 | - public static function get_recent_donations_global( $limit = 5, $currency = '', $payment_mode = '' ) { | |
| 1149 | + public static function get_recent_donations_global( $limit = 5 ) { | |
| 1785 | 1150 | $instance = self::get_instance(); |
| 1786 | 1151 | global $wpdb; |
| 1787 | 1152 | |
| 1788 | - $args = [ $instance->get_tablename() ]; | |
| 1789 | - $extra = self::scope_fragment( $currency, $payment_mode, $args ); | |
| 1790 | - $args[] = absint( $limit ); | |
| 1153 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1154 | + $results = $wpdb->get_results( | |
| 1155 | + $wpdb->prepare( | |
| 1156 | + "SELECT * FROM %i WHERE payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d", | |
| 1157 | + $instance->get_tablename(), | |
| 1158 | + absint( $limit ) | |
| 1159 | + ), | |
| 1160 | + ARRAY_A | |
| 1161 | + ); | |
| 1791 | 1162 | |
| 1792 | - $sql = "SELECT * FROM %i | |
| 1793 | - WHERE payment_status IN ('completed', 'partially_refunded') | |
| 1794 | - {$extra} | |
| 1795 | - ORDER BY created_at DESC | |
| 1796 | - LIMIT %d"; | |
| 1797 | - | |
| 1798 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args. | |
| 1799 | - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A ); | |
| 1800 | - | |
| 1801 | 1163 | if ( ! $results || ! is_array( $results ) ) { |
| 1802 | 1164 | return []; |
| 1803 | 1165 | } |
| 1804 | 1166 | |
| @@ -1807,45 +1169,35 @@ | ||
| 1807 | 1169 | |
| 1808 | 1170 | /** |
| 1809 | 1171 | * Get top campaigns by donations. |
| 1810 | 1172 | * |
| 1811 | - * @param int $limit Number of campaigns to retrieve. | |
| 1812 | - * @param string $currency Currency code to scope to ('' for no filter). | |
| 1813 | - * @param string $payment_mode 'test' or 'live' ('' for no filter). | |
| 1173 | + * @param int $limit Number of campaigns to retrieve. | |
| 1814 | 1174 | * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats. |
| 1815 | 1175 | * @since 0.0.1 |
| 1816 | 1176 | */ |
| 1817 | - public static function get_top_campaigns( $limit = 5, $currency = '', $payment_mode = '' ) { | |
| 1177 | + public static function get_top_campaigns( $limit = 5 ) { | |
| 1818 | 1178 | $instance = self::get_instance(); |
| 1819 | 1179 | global $wpdb; |
| 1820 | 1180 | |
| 1821 | - $args = [ $instance->get_tablename(), SUREDONATION_POST_TYPE ]; | |
| 1822 | - $extra = self::scope_fragment( $currency, $payment_mode, $args ); | |
| 1823 | - $args[] = absint( $limit ); | |
| 1824 | - | |
| 1825 | - // The join is what makes LIMIT meaningful: orphaned campaign_ids (post | |
| 1826 | - // deleted, donations kept) still carry donations, so filtering them in | |
| 1827 | - // PHP after a SQL LIMIT returned fewer than the requested top-N while | |
| 1828 | - // valid campaigns sat below the cut. | |
| 1829 | - $sql = "SELECT | |
| 1830 | - d.campaign_id, | |
| 1831 | - p.post_title AS campaign_title, | |
| 1181 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1182 | + $results = $wpdb->get_results( | |
| 1183 | + $wpdb->prepare( | |
| 1184 | + "SELECT | |
| 1185 | + campaign_id, | |
| 1832 | 1186 | COUNT(*) as donation_count, |
| 1833 | 1187 | COALESCE(SUM(amount - refunded_amount), 0) as total_raised, |
| 1834 | 1188 | COUNT(DISTINCT donor_email) as unique_donors |
| 1835 | - FROM %i AS d | |
| 1836 | - INNER JOIN {$wpdb->posts} AS p | |
| 1837 | - ON p.ID = d.campaign_id | |
| 1838 | - AND p.post_type = %s | |
| 1189 | + FROM %i | |
| 1839 | 1190 | WHERE payment_status IN ('completed', 'partially_refunded') |
| 1840 | - {$extra} | |
| 1841 | - GROUP BY d.campaign_id | |
| 1191 | + GROUP BY campaign_id | |
| 1842 | 1192 | ORDER BY total_raised DESC |
| 1843 | - LIMIT %d"; | |
| 1193 | + LIMIT %d", | |
| 1194 | + $instance->get_tablename(), | |
| 1195 | + absint( $limit ) | |
| 1196 | + ), | |
| 1197 | + ARRAY_A | |
| 1198 | + ); | |
| 1844 | 1199 | |
| 1845 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value travels in $args. | |
| 1846 | - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A ); | |
| 1847 | - | |
| 1848 | 1200 | return $results ? $results : []; |
| 1849 | 1201 | } |
| 1850 | 1202 | |
| 1851 | 1203 | /** |
| @@ -1850,18 +1202,15 @@ | ||
| 1850 | 1202 | |
| 1851 | 1203 | /** |
| 1852 | 1204 | * Get donation trends over time. |
| 1853 | 1205 | * |
| 1854 | - * @param string $after Start date (ISO format). | |
| 1855 | - * @param string $before End date (ISO format). | |
| 1856 | - * @param string $group Grouping: 'day', 'week', or 'month'. | |
| 1857 | - * @param string $currency Currency code to scope to ('' for no currency filter). | |
| 1858 | - * @param int $campaign_id Campaign to scope to (0 for all campaigns). | |
| 1859 | - * @param string $payment_mode 'test' or 'live' ('' for no filter). | |
| 1206 | + * @param string $after Start date (ISO format). | |
| 1207 | + * @param string $before End date (ISO format). | |
| 1208 | + * @param string $group Grouping: 'day', 'week', or 'month'. | |
| 1860 | 1209 | * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends. |
| 1861 | 1210 | * @since 0.0.1 |
| 1862 | 1211 | */ |
| 1863 | - public static function get_donation_trends( $after = '', $before = '', $group = 'day', $currency = '', $campaign_id = 0, $payment_mode = '' ) { | |
| 1212 | + public static function get_donation_trends( $after = '', $before = '', $group = 'day' ) { | |
| 1864 | 1213 | $instance = self::get_instance(); |
| 1865 | 1214 | global $wpdb; |
| 1866 | 1215 | |
| 1867 | 1216 | // Default to last 30 days if no dates provided. |
| @@ -1885,33 +1234,12 @@ | ||
| 1885 | 1234 | $date_format = '%Y-%m-%d'; |
| 1886 | 1235 | break; |
| 1887 | 1236 | } |
| 1888 | 1237 | |
| 1889 | - // Amounts of different currencies cannot be summed into one figure, so | |
| 1890 | - // scope the query to a single currency. Callers that don't care still | |
| 1891 | - // get coherent numbers because the default is the store currency. | |
| 1892 | - $currency = is_string( $currency ) ? strtoupper( trim( $currency ) ) : ''; | |
| 1893 | - $extra = ''; | |
| 1894 | - $args = [ $date_format, $instance->get_tablename(), $after, $before ]; | |
| 1895 | - | |
| 1896 | - if ( '' !== $currency ) { | |
| 1897 | - $extra .= ' AND currency = %s'; | |
| 1898 | - $args[] = $currency; | |
| 1899 | - } | |
| 1900 | - | |
| 1901 | - if ( $campaign_id > 0 ) { | |
| 1902 | - $extra .= ' AND campaign_id = %d'; | |
| 1903 | - $args[] = absint( $campaign_id ); | |
| 1904 | - } | |
| 1905 | - | |
| 1906 | - // Test and live donations must not be summed together either. | |
| 1907 | - $payment_mode = is_string( $payment_mode ) ? strtolower( trim( $payment_mode ) ) : ''; | |
| 1908 | - if ( in_array( $payment_mode, [ 'test', 'live' ], true ) ) { | |
| 1909 | - $extra .= ' AND payment_mode = %s'; | |
| 1910 | - $args[] = $payment_mode; | |
| 1911 | - } | |
| 1912 | - | |
| 1913 | - $sql = "SELECT | |
| 1238 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1239 | + $results = $wpdb->get_results( | |
| 1240 | + $wpdb->prepare( | |
| 1241 | + "SELECT | |
| 1914 | 1242 | DATE_FORMAT(created_at, %s) as period, |
| 1915 | 1243 | COUNT(*) as donation_count, |
| 1916 | 1244 | COALESCE(SUM(amount - refunded_amount), 0) as total_amount |
| 1917 | 1245 | FROM %i |
| @@ -1917,202 +1245,22 @@ | ||
| 1917 | 1245 | FROM %i |
| 1918 | 1246 | WHERE payment_status IN ('completed', 'partially_refunded') |
| 1919 | 1247 | AND DATE(created_at) >= %s |
| 1920 | 1248 | AND DATE(created_at) <= %s |
| 1921 | - {$extra} | |
| 1922 | 1249 | GROUP BY period |
| 1923 | - ORDER BY period ASC"; | |
| 1924 | - | |
| 1925 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $extra is built only from static placeholder fragments; every value is passed through prepare args. | |
| 1926 | - $results = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A ); | |
| 1927 | - | |
| 1928 | - return $results ? $results : []; | |
| 1929 | - } | |
| 1930 | - | |
| 1931 | - /** | |
| 1932 | - * Count donations recorded through a donation form, in any status. | |
| 1933 | - * | |
| 1934 | - * Used to protect a form from permanent deletion while donation rows still | |
| 1935 | - * reference it, mirroring count_by_campaign()'s role for campaigns. | |
| 1936 | - * | |
| 1937 | - * @param int $form_id Donation form post ID. | |
| 1938 | - * @return int Donation count. | |
| 1939 | - * @since 1.5.0 | |
| 1940 | - */ | |
| 1941 | - public static function count_by_form( $form_id ) { | |
| 1942 | - $instance = self::get_instance(); | |
| 1943 | - global $wpdb; | |
| 1944 | - | |
| 1945 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Guard on a destructive action; must read live data. | |
| 1946 | - $count = $wpdb->get_var( | |
| 1947 | - $wpdb->prepare( | |
| 1948 | - 'SELECT COUNT(*) FROM %i WHERE form_id = %d', | |
| 1250 | + ORDER BY period ASC", | |
| 1251 | + $date_format, | |
| 1949 | 1252 | $instance->get_tablename(), |
| 1950 | - absint( $form_id ) | |
| 1951 | - ) | |
| 1952 | - ); | |
| 1953 | - | |
| 1954 | - return is_numeric( $count ) ? (int) $count : 0; | |
| 1955 | - } | |
| 1956 | - | |
| 1957 | - /** | |
| 1958 | - * Get completed entry count and revenue for a single donation form. | |
| 1959 | - * | |
| 1960 | - * @param int $form_id Donation form post ID. | |
| 1961 | - * @return array{entries: int, revenue: float} Form totals. | |
| 1962 | - * @since 1.5.0 | |
| 1963 | - */ | |
| 1964 | - public static function get_form_stats( $form_id ) { | |
| 1965 | - $instance = self::get_instance(); | |
| 1966 | - global $wpdb; | |
| 1967 | - | |
| 1968 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live totals; caching would show stale figures. | |
| 1969 | - $result = $wpdb->get_row( | |
| 1970 | - $wpdb->prepare( | |
| 1971 | - 'SELECT COUNT(*) as entries, COALESCE(SUM(amount - refunded_amount), 0) as revenue FROM %i WHERE form_id = %d AND payment_status = %s', | |
| 1972 | - $instance->get_tablename(), | |
| 1973 | - absint( $form_id ), | |
| 1974 | - 'completed' | |
| 1253 | + $after, | |
| 1254 | + $before | |
| 1975 | 1255 | ), |
| 1976 | 1256 | ARRAY_A |
| 1977 | 1257 | ); |
| 1978 | 1258 | |
| 1979 | - return [ | |
| 1980 | - 'entries' => is_array( $result ) ? (int) ( $result['entries'] ?? 0 ) : 0, | |
| 1981 | - 'revenue' => is_array( $result ) ? (float) ( $result['revenue'] ?? 0 ) : 0.0, | |
| 1982 | - ]; | |
| 1259 | + return $results ? $results : []; | |
| 1983 | 1260 | } |
| 1984 | 1261 | |
| 1985 | 1262 | /** |
| 1986 | - * Get entry and revenue totals for several forms in one query. | |
| 1987 | - * | |
| 1988 | - * get_form_stats() is a per-form query, so formatting a page of N forms ran | |
| 1989 | - * N COUNT/SUM queries. This collapses that to one GROUP BY for the page. | |
| 1990 | - * | |
| 1991 | - * @param array<int> $form_ids Form IDs to total. | |
| 1992 | - * @return array<int, array{entries: int, revenue: float}> Totals keyed by form ID; every requested ID is present. | |
| 1993 | - * @since 1.5.0 | |
| 1994 | - */ | |
| 1995 | - public static function get_form_stats_bulk( array $form_ids ) { | |
| 1996 | - // intval, not absint: absint( -1 ) is 1, which would silently total a | |
| 1997 | - // real form the caller never asked about. | |
| 1998 | - $ids = array_values( | |
| 1999 | - array_unique( | |
| 2000 | - array_filter( | |
| 2001 | - array_map( 'intval', $form_ids ), | |
| 2002 | - static function ( $id ) { | |
| 2003 | - return $id > 0; | |
| 2004 | - } | |
| 2005 | - ) | |
| 2006 | - ) | |
| 2007 | - ); | |
| 2008 | - | |
| 2009 | - // Every requested id gets an entry, so callers never have to special-case | |
| 2010 | - // a form that simply has no donations yet. | |
| 2011 | - $stats = []; | |
| 2012 | - foreach ( $ids as $id ) { | |
| 2013 | - $stats[ $id ] = [ | |
| 2014 | - 'entries' => 0, | |
| 2015 | - 'revenue' => 0.0, | |
| 2016 | - ]; | |
| 2017 | - } | |
| 2018 | - | |
| 2019 | - if ( empty( $ids ) ) { | |
| 2020 | - return $stats; | |
| 2021 | - } | |
| 2022 | - | |
| 2023 | - $instance = self::get_instance(); | |
| 2024 | - global $wpdb; | |
| 2025 | - | |
| 2026 | - $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); | |
| 2027 | - | |
| 2028 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Live totals; placeholders are generated from a count, every value is bound. | |
| 2029 | - $rows = $wpdb->get_results( | |
| 2030 | - $wpdb->prepare( | |
| 2031 | - sprintf( | |
| 2032 | - 'SELECT form_id, COUNT(*) as entries, COALESCE(SUM(amount - refunded_amount), 0) as revenue FROM %%i WHERE form_id IN ( %s ) AND payment_status = %%s GROUP BY form_id', | |
| 2033 | - $placeholders | |
| 2034 | - ), | |
| 2035 | - array_merge( [ $instance->get_tablename() ], $ids, [ 'completed' ] ) | |
| 2036 | - ), | |
| 2037 | - ARRAY_A | |
| 2038 | - ); | |
| 2039 | - | |
| 2040 | - if ( ! is_array( $rows ) ) { | |
| 2041 | - return $stats; | |
| 2042 | - } | |
| 2043 | - | |
| 2044 | - foreach ( $rows as $row ) { | |
| 2045 | - if ( ! is_array( $row ) ) { | |
| 2046 | - continue; | |
| 2047 | - } | |
| 2048 | - | |
| 2049 | - $form_id = absint( $row['form_id'] ?? 0 ); | |
| 2050 | - if ( ! isset( $stats[ $form_id ] ) ) { | |
| 2051 | - continue; | |
| 2052 | - } | |
| 2053 | - | |
| 2054 | - $stats[ $form_id ] = [ | |
| 2055 | - 'entries' => (int) ( $row['entries'] ?? 0 ), | |
| 2056 | - 'revenue' => (float) ( $row['revenue'] ?? 0 ), | |
| 2057 | - ]; | |
| 2058 | - } | |
| 2059 | - | |
| 2060 | - return $stats; | |
| 2061 | - } | |
| 2062 | - | |
| 2063 | - /** | |
| 2064 | - * Count donations matching the admin-list filters. | |
| 2065 | - * | |
| 2066 | - * Mirrors get_admin_list()'s WHERE clause, including the search term. The | |
| 2067 | - * older get_total_donations_by_status() ignores `$search`, so any searched | |
| 2068 | - * listing reported the unfiltered total and paginated against it. | |
| 2069 | - * | |
| 2070 | - * @param string $status Payment status filter ('all' for no filter). | |
| 2071 | - * @param int $campaign_id Campaign ID filter (0 for no filter). | |
| 2072 | - * @param string $search Search term for donor_name, donor_email, or transaction_id. | |
| 2073 | - * @return int Matching row count. | |
| 2074 | - * @since 1.5.0 | |
| 2075 | - */ | |
| 2076 | - public static function count_admin_list( $status = 'all', $campaign_id = 0, $search = '' ) { | |
| 2077 | - $instance = self::get_instance(); | |
| 2078 | - global $wpdb; | |
| 2079 | - | |
| 2080 | - $conditions = [ '1=1' ]; | |
| 2081 | - $args = [ $instance->get_tablename() ]; | |
| 2082 | - | |
| 2083 | - if ( 'all' !== $status ) { | |
| 2084 | - $conditions[] = 'payment_status = %s'; | |
| 2085 | - $args[] = sanitize_text_field( $status ); | |
| 2086 | - } else { | |
| 2087 | - // Mirrors get_admin_list(): abandoned rows are out of the unfiltered | |
| 2088 | - // listing, so the total has to leave them out too or the last page | |
| 2089 | - // comes back short. | |
| 2090 | - $conditions[] = "payment_status != 'abandoned'"; | |
| 2091 | - } | |
| 2092 | - | |
| 2093 | - if ( $campaign_id > 0 ) { | |
| 2094 | - $conditions[] = 'campaign_id = %d'; | |
| 2095 | - $args[] = absint( $campaign_id ); | |
| 2096 | - } | |
| 2097 | - | |
| 2098 | - if ( ! empty( $search ) ) { | |
| 2099 | - $conditions[] = '(donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s)'; | |
| 2100 | - $term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%'; | |
| 2101 | - $args[] = $term; | |
| 2102 | - $args[] = $term; | |
| 2103 | - $args[] = $term; | |
| 2104 | - } | |
| 2105 | - | |
| 2106 | - $where = implode( ' AND ', $conditions ); | |
| 2107 | - | |
| 2108 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args. | |
| 2109 | - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", $args ) ); | |
| 2110 | - | |
| 2111 | - return is_numeric( $count ) ? (int) $count : 0; | |
| 2112 | - } | |
| 2113 | - | |
| 2114 | - /** | |
| 2115 | 1263 | * Get recent donations for a campaign. |
| 2116 | 1264 | * |
| 2117 | 1265 | * @param int $campaign_id Campaign ID. |
| 2118 | 1266 | * @param int $limit Number of donations to retrieve. |
| @@ -2304,42 +1452,8 @@ | ||
| 2304 | 1452 | return self::$valid_statuses; |
| 2305 | 1453 | } |
| 2306 | 1454 | |
| 2307 | 1455 | /** |
| 2308 | - * Get valid donor-comment moderation statuses. | |
| 2309 | - * | |
| 2310 | - * @return array<string> Valid donor-comment statuses. | |
| 2311 | - * @since 1.6.0 | |
| 2312 | - */ | |
| 2313 | - public static function get_valid_comment_statuses() { | |
| 2314 | - return self::$valid_comment_statuses; | |
| 2315 | - } | |
| 2316 | - | |
| 2317 | - /** | |
| 2318 | - * Resolve the moderation status a newly captured donor comment should get. | |
| 2319 | - * | |
| 2320 | - * Held for review only when the site owner has opted in; otherwise comments | |
| 2321 | - * publish straight away, matching how GiveWP and Charitable behave out of the | |
| 2322 | - * box. An empty comment gets `approved` so a donation with nothing to moderate | |
| 2323 | - * never shows up in a review queue. | |
| 2324 | - * | |
| 2325 | - * @param string $comment The captured comment. | |
| 2326 | - * @return string One of self::$valid_comment_statuses. | |
| 2327 | - * @since 1.6.0 | |
| 2328 | - */ | |
| 2329 | - public static function initial_comment_status( $comment ) { | |
| 2330 | - if ( '' === trim( Helper::get_string_value( $comment ) ) ) { | |
| 2331 | - return 'approved'; | |
| 2332 | - } | |
| 2333 | - | |
| 2334 | - $donor_settings = Helper::get_array_value( | |
| 2335 | - Helper::get_suredonation_option( \SureDonation\Inc\API\Settings_API::DONOR_OPTION_KEY, [] ) | |
| 2336 | - ); | |
| 2337 | - | |
| 2338 | - return ! empty( $donor_settings['hold_donor_comments'] ) ? 'pending' : 'approved'; | |
| 2339 | - } | |
| 2340 | - | |
| 2341 | - /** | |
| 2342 | 1456 | * Add a log entry to a donation. |
| 2343 | 1457 | * |
| 2344 | 1458 | * @param int $donation_id Donation ID. |
| 2345 | 1459 | * @param string $action Action type (e.g., 'status_change', 'refund', 'webhook'). |
| @@ -2451,55 +1565,8 @@ | ||
| 2451 | 1565 | } |
| 2452 | 1566 | |
| 2453 | 1567 | // Store with refund ID as key for O(1) lookup (duplicate prevention). |
| 2454 | 1568 | $donation_data['refunds'][ $refund_id ] = $refund_data; |
| 2455 | - | |
| 2456 | - // Update donation_data in database. | |
| 2457 | - $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] ); | |
| 2458 | - | |
| 2459 | - return false !== $result; | |
| 2460 | - } | |
| 2461 | - | |
| 2462 | - /** | |
| 2463 | - * Store the submitted form field values under the donation_data['fields'] key. | |
| 2464 | - * | |
| 2465 | - * The donation_data column is shared JSON (also holds refunds, notes and | |
| 2466 | - * subscription metadata), so the field data is merged under a dedicated | |
| 2467 | - * 'fields' key and never overwrites the column. | |
| 2468 | - * | |
| 2469 | - * Fields are written at donation creation (before the payment is confirmed) | |
| 2470 | - * and are intentionally retained for abandoned/failed donations — pending | |
| 2471 | - * records are legitimate business data (recovery, reconciliation, reporting). | |
| 2472 | - * There is deliberately no automatic PII purge here; erasure is handled on | |
| 2473 | - * demand via the admin delete actions (and can be wired to WordPress's | |
| 2474 | - * personal-data eraser hooks if a retention policy is later required). | |
| 2475 | - * | |
| 2476 | - * @param int $donation_id Donation ID. | |
| 2477 | - * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs. | |
| 2478 | - * @return bool True on success, false on failure. | |
| 2479 | - * @since 1.1.1 | |
| 2480 | - */ | |
| 2481 | - public static function set_submitted_fields( $donation_id, $field_data ) { | |
| 2482 | - if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) { | |
| 2483 | - return false; | |
| 2484 | - } | |
| 2485 | - | |
| 2486 | - $donation = self::get( $donation_id ); | |
| 2487 | - if ( ! $donation ) { | |
| 2488 | - return false; | |
| 2489 | - } | |
| 2490 | - | |
| 2491 | - // Get existing donation_data. | |
| 2492 | - $donation_data = $donation['donation_data'] ?? []; | |
| 2493 | - if ( is_string( $donation_data ) && ! empty( $donation_data ) ) { | |
| 2494 | - $donation_data = json_decode( $donation_data, true ); | |
| 2495 | - } | |
| 2496 | - if ( ! is_array( $donation_data ) ) { | |
| 2497 | - $donation_data = []; | |
| 2498 | - } | |
| 2499 | - | |
| 2500 | - // Merge under a dedicated key — never overwrite the shared column. | |
| 2501 | - $donation_data['fields'] = $field_data; | |
| 2502 | 1569 | |
| 2503 | 1570 | // Update donation_data in database. |
| 2504 | 1571 | $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] ); |
| 2505 | 1572 | |