| @@ -36,9 +36,9 @@ | ||
| 36 | 36 | * |
| 37 | 37 | * @var int |
| 38 | 38 | * @since 0.0.1 |
| 39 | 39 | */ |
| 40 | - protected $table_version = 5; | |
| 40 | + protected $table_version = 4; | |
| 41 | 41 | |
| 42 | 42 | /** |
| 43 | 43 | * Valid payment statuses. |
| 44 | 44 | * |
| @@ -117,12 +117,8 @@ | ||
| 117 | 117 | 'customer_id' => [ |
| 118 | 118 | 'type' => 'string', |
| 119 | 119 | 'default' => '', |
| 120 | 120 | ], |
| 121 | - 'stripe_account_id' => [ | |
| 122 | - 'type' => 'string', | |
| 123 | - 'default' => '', | |
| 124 | - ], | |
| 125 | 121 | 'gateway' => [ |
| 126 | 122 | 'type' => 'string', |
| 127 | 123 | 'default' => 'stripe', |
| 128 | 124 | ], |
| @@ -229,9 +225,8 @@ | ||
| 229 | 225 | 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0', |
| 230 | 226 | 'currency VARCHAR(10) NOT NULL', |
| 231 | 227 | 'transaction_id VARCHAR(255) NOT NULL', |
| 232 | 228 | 'customer_id VARCHAR(50) NOT NULL', |
| 233 | - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\'', | |
| 234 | 229 | 'gateway VARCHAR(20) NOT NULL', |
| 235 | 230 | 'payment_status VARCHAR(50) NOT NULL', |
| 236 | 231 | 'payment_mode VARCHAR(20) NOT NULL', |
| 237 | 232 | 'donor_name VARCHAR(255) NOT NULL', |
| @@ -250,9 +245,9 @@ | ||
| 250 | 245 | 'ip_address VARCHAR(45) NOT NULL', |
| 251 | 246 | 'user_agent TEXT', |
| 252 | 247 | 'referer_url TEXT', |
| 253 | 248 | 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0', |
| 254 | - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'', | |
| 249 | + 'import_source VARCHAR(20) NOT NULL DEFAULT ""', | |
| 255 | 250 | 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', |
| 256 | 251 | 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', |
| 257 | 252 | 'INDEX idx_campaign (campaign_id)', |
| 258 | 253 | 'INDEX idx_donor (donor_id)', |
| @@ -263,9 +258,8 @@ | ||
| 263 | 258 | 'INDEX idx_subscription (subscription_id)', |
| 264 | 259 | 'INDEX idx_subscription_status (subscription_status)', |
| 265 | 260 | 'INDEX idx_parent_subscription (parent_subscription_id)', |
| 266 | 261 | 'INDEX idx_import_source (import_source_id, import_source)', |
| 267 | - 'INDEX idx_stripe_account (stripe_account_id)', | |
| 268 | 262 | ]; |
| 269 | 263 | } |
| 270 | 264 | |
| 271 | 265 | /** |
| @@ -272,11 +266,9 @@ | ||
| 272 | 266 | * New columns added across versions. |
| 273 | 267 | * |
| 274 | 268 | * Version 2 added subscription support; version 4 added the |
| 275 | 269 | * source-agnostic pair `import_source_id` + `import_source` used by |
| 276 | - * the migration tool for duplicate detection and rollback; version 5 | |
| 277 | - * added `stripe_account_id` so donations record which connected Stripe | |
| 278 | - * account processed them (multiple Stripe accounts support). | |
| 270 | + * the migration tool for duplicate detection and rollback. | |
| 279 | 271 | * |
| 280 | 272 | * {@inheritDoc} |
| 281 | 273 | * |
| 282 | 274 | * @since 1.0.0 |
| @@ -286,76 +278,17 @@ | ||
| 286 | 278 | 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type', |
| 287 | 279 | 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id', |
| 288 | 280 | 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status', |
| 289 | 281 | 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url', |
| 290 | - 'import_source VARCHAR(20) NOT NULL DEFAULT \'\' AFTER import_source_id', | |
| 291 | - 'stripe_account_id VARCHAR(50) NOT NULL DEFAULT \'\' AFTER customer_id', | |
| 282 | + 'import_source VARCHAR(20) NOT NULL DEFAULT "" AFTER import_source_id', | |
| 292 | 283 | 'INDEX idx_subscription (subscription_id)', |
| 293 | 284 | 'INDEX idx_subscription_status (subscription_status)', |
| 294 | 285 | 'INDEX idx_parent_subscription (parent_subscription_id)', |
| 295 | 286 | 'INDEX idx_import_source (import_source_id, import_source)', |
| 296 | - 'INDEX idx_stripe_account (stripe_account_id)', | |
| 297 | 287 | ]; |
| 298 | 288 | } |
| 299 | 289 | |
| 300 | 290 | /** |
| 301 | - * One-time data migrations for the donations table. | |
| 302 | - * | |
| 303 | - * Version 5 introduced the `stripe_account_id` column. Before multi-account there | |
| 304 | - * could only be a single connected Stripe account, so every pre-v5 Stripe | |
| 305 | - * donation belongs to the current (single) default account. Backfill it so | |
| 306 | - * refunds and subscription lifecycle actions keep routing to the originating | |
| 307 | - * account after a second account is connected and the default is switched. | |
| 308 | - * Idempotent (touches only empty rows) and gated to the upgrade into v5. | |
| 309 | - * | |
| 310 | - * @return void | |
| 311 | - * @since 1.3.0 | |
| 312 | - */ | |
| 313 | - public function run_data_migrations() { | |
| 314 | - // A failed CREATE/ALTER earlier in this upgrade already cleared the flag; | |
| 315 | - // the column may not exist, so don't run an UPDATE against it. | |
| 316 | - if ( ! $this->db_upgradable ) { | |
| 317 | - return; | |
| 318 | - } | |
| 319 | - | |
| 320 | - // Already on v5+ (e.g. a later upgrade) — the backfill is done. | |
| 321 | - if ( $this->prev_version >= 5 ) { | |
| 322 | - return; | |
| 323 | - } | |
| 324 | - | |
| 325 | - if ( ! class_exists( '\SureDonation\Inc\Payments\Stripe\Stripe_Helper' ) ) { | |
| 326 | - return; | |
| 327 | - } | |
| 328 | - | |
| 329 | - // Runs during the v5 DB upgrade — before any second account can be | |
| 330 | - // connected via the UI — so the default is still the single legacy account. | |
| 331 | - $account_id = \SureDonation\Inc\Payments\Stripe\Stripe_Helper::get_default_account_id(); | |
| 332 | - if ( ! is_string( $account_id ) || '' === $account_id ) { | |
| 333 | - return; | |
| 334 | - } | |
| 335 | - | |
| 336 | - global $wpdb; | |
| 337 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time backfill of a newly added column; not cacheable. | |
| 338 | - $result = $wpdb->query( | |
| 339 | - $wpdb->prepare( | |
| 340 | - 'UPDATE %i SET stripe_account_id = %s WHERE gateway = %s AND ( stripe_account_id = %s OR stripe_account_id IS NULL )', | |
| 341 | - $this->get_tablename(), | |
| 342 | - $account_id, | |
| 343 | - 'stripe', | |
| 344 | - '' | |
| 345 | - ) | |
| 346 | - ); | |
| 347 | - | |
| 348 | - // A transient failure (e.g. lock wait timeout on a busy table) must not | |
| 349 | - // persist the new version: `prev_version >= 5` would then skip this | |
| 350 | - // one-shot backfill forever. Leaving the version unwritten makes the | |
| 351 | - // idempotent sequence retry on the next request. | |
| 352 | - if ( false === $result ) { | |
| 353 | - $this->db_upgradable = false; | |
| 354 | - } | |
| 355 | - } | |
| 356 | - | |
| 357 | - /** | |
| 358 | 291 | * Add a new donation record. |
| 359 | 292 | * |
| 360 | 293 | * @param array<mixed> $data Donation data to insert. |
| 361 | 294 | * @return int|false The donation ID on success, false on error. |
| @@ -386,48 +319,16 @@ | ||
| 386 | 319 | $donation_id = absint( $result ); |
| 387 | 320 | $donation = self::get( $donation_id ); |
| 388 | 321 | $donation = is_array( $donation ) ? $donation : []; |
| 389 | 322 | |
| 390 | - // Curated payload (internal/gateway-only columns omitted; donor | |
| 391 | - // identity included, see the note in get_integration_payload()) | |
| 392 | - // shared by every hook below. | |
| 393 | - $payload = self::get_integration_payload( $donation ); | |
| 394 | - | |
| 395 | 323 | /** |
| 396 | 324 | * Fires when a new donation record is created. |
| 397 | 325 | * |
| 398 | 326 | * @param int $donation_id Newly created donation ID. |
| 399 | - * @param array<mixed> $donation Curated donation payload. | |
| 327 | + * @param array<mixed> $donation Complete donation record. | |
| 400 | 328 | * @since 1.1.0 |
| 401 | 329 | */ |
| 402 | - do_action( 'suredonation_donation_created', $donation_id, $payload ); | |
| 403 | - | |
| 404 | - /** | |
| 405 | - * Fires when a new donation record is created. | |
| 406 | - * | |
| 407 | - * Mirrors `suredonation_donation_created`; the OttoKit (formerly | |
| 408 | - * SureTriggers) "New Donation" trigger listens on this hook name. | |
| 409 | - * | |
| 410 | - * @param int $donation_id Newly created donation ID. | |
| 411 | - * @param array<mixed> $donation Curated donation payload. | |
| 412 | - * @since 1.2.0 | |
| 413 | - */ | |
| 414 | - do_action( 'suredonation_new_donation', $donation_id, $payload ); | |
| 415 | - | |
| 416 | - // Some donations are created already-completed rather than | |
| 417 | - // transitioning through update() — recurring renewals and | |
| 418 | - // admin-recorded paid donations. Fire the completion event here | |
| 419 | - // too so integration hooks still see them. | |
| 420 | - if ( 'completed' === ( $data['payment_status'] ?? '' ) ) { | |
| 421 | - /** | |
| 422 | - * Fires when a donation payment is completed. | |
| 423 | - * | |
| 424 | - * @param int $donation_id Donation ID. | |
| 425 | - * @param array<mixed> $donation Curated donation payload after insertion. | |
| 426 | - * @since 1.2.0 | |
| 427 | - */ | |
| 428 | - do_action( 'suredonation_donation_completed', $donation_id, $payload ); | |
| 429 | - } | |
| 330 | + do_action( 'suredonation_donation_created', $donation_id, $donation ); | |
| 430 | 331 | } |
| 431 | 332 | } |
| 432 | 333 | |
| 433 | 334 | return $result; |
| @@ -445,17 +346,15 @@ | ||
| 445 | 346 | if ( empty( $donation_id ) ) { |
| 446 | 347 | return false; |
| 447 | 348 | } |
| 448 | 349 | |
| 449 | - // Capture the current status and refunded amount before the write so | |
| 450 | - // integration hooks (e.g. OttoKit) can react to the transition and to | |
| 451 | - // refund events, not just the resulting values. | |
| 452 | - $old_status = ''; | |
| 453 | - $old_refunded = 0.0; | |
| 454 | - if ( isset( $data['payment_status'] ) || isset( $data['refunded_amount'] ) ) { | |
| 455 | - $existing = self::get( absint( $donation_id ) ); | |
| 456 | - $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : ''; | |
| 457 | - $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'] ?? '' ) : ''; | |
| 458 | 357 | } |
| 459 | 358 | |
| 460 | 359 | // Set updated_at. |
| 461 | 360 | $data['updated_at'] = current_time( 'mysql' ); |
| @@ -465,22 +364,22 @@ | ||
| 465 | 364 | // Status/amount changes (e.g. a webhook completing a pending donation) |
| 466 | 365 | // affect the cached stats and donor lists. |
| 467 | 366 | if ( $updated ) { |
| 468 | 367 | $donation = self::get( absint( $donation_id ) ); |
| 469 | - $donation = is_array( $donation ) ? $donation : []; | |
| 470 | 368 | if ( ! empty( $donation['campaign_id'] ) ) { |
| 471 | 369 | Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) ); |
| 472 | 370 | } |
| 473 | 371 | |
| 474 | - // Curated payload (internal/gateway-only columns omitted; donor | |
| 475 | - // identity included, see the note in get_integration_payload()) | |
| 476 | - // shared by every hook below. | |
| 477 | - $payload = self::get_integration_payload( $donation ); | |
| 478 | - | |
| 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. | |
| 479 | 376 | if ( isset( $data['payment_status'] ) ) { |
| 480 | 377 | $new_status = Helper::get_string_value( $data['payment_status'] ); |
| 481 | 378 | |
| 482 | 379 | if ( $new_status !== $old_status ) { |
| 380 | + $donation = is_array( $donation ) ? $donation : []; | |
| 381 | + | |
| 483 | 382 | /** |
| 484 | 383 | * Fires when a donation's payment status changes. |
| 485 | 384 | * |
| 486 | 385 | * @param int $donation_id Donation ID. |
| @@ -485,51 +384,14 @@ | ||
| 485 | 384 | * |
| 486 | 385 | * @param int $donation_id Donation ID. |
| 487 | 386 | * @param string $new_status New payment status. |
| 488 | 387 | * @param string $old_status Previous payment status (empty string if unknown). |
| 489 | - * @param array<mixed> $donation Curated donation payload after the update. | |
| 388 | + * @param array<mixed> $donation Complete donation record after the update. | |
| 490 | 389 | * @since 1.1.0 |
| 491 | 390 | */ |
| 492 | - do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $payload ); | |
| 493 | - | |
| 494 | - // Fire the completion event for any genuine transition into | |
| 495 | - // 'completed' — including admin review states (suspicious, | |
| 496 | - // cancelled) — but never for refund reversals that restore | |
| 497 | - // the 'completed' status (refunded/partially_refunded -> | |
| 498 | - // completed), which would replay the completion automation. | |
| 499 | - if ( 'completed' === $new_status && ! in_array( $old_status, [ 'completed', 'refunded', 'partially_refunded' ], true ) ) { | |
| 500 | - /** | |
| 501 | - * Fires when a donation payment is completed. | |
| 502 | - * | |
| 503 | - * @param int $donation_id Donation ID. | |
| 504 | - * @param array<mixed> $donation Curated donation payload after the update. | |
| 505 | - * @since 1.2.0 | |
| 506 | - */ | |
| 507 | - do_action( 'suredonation_donation_completed', absint( $donation_id ), $payload ); | |
| 508 | - } | |
| 391 | + do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $donation ); | |
| 509 | 392 | } |
| 510 | 393 | } |
| 511 | - | |
| 512 | - // A rise in refunded_amount means a refund was processed. Keying off | |
| 513 | - // the amount (not the status string) catches repeat partial refunds | |
| 514 | - // that leave the status as partially_refunded, and excludes refund | |
| 515 | - // reversals where the amount drops. | |
| 516 | - if ( isset( $data['refunded_amount'] ) ) { | |
| 517 | - $new_refunded = Helper::get_float_value( $data['refunded_amount'] ); | |
| 518 | - | |
| 519 | - if ( $new_refunded - $old_refunded > 0.0001 ) { | |
| 520 | - /** | |
| 521 | - * Fires when a donation is refunded, fully or partially. | |
| 522 | - * | |
| 523 | - * @param int $donation_id Donation ID. | |
| 524 | - * @param float $refund_amount Amount refunded in this event. | |
| 525 | - * @param float $total_refunded Cumulative amount refunded to date. | |
| 526 | - * @param array<mixed> $donation Curated donation payload after the update. | |
| 527 | - * @since 1.2.0 | |
| 528 | - */ | |
| 529 | - do_action( 'suredonation_donation_refunded', absint( $donation_id ), $new_refunded - $old_refunded, $new_refunded, $payload ); | |
| 530 | - } | |
| 531 | - } | |
| 532 | 394 | } |
| 533 | 395 | |
| 534 | 396 | return $updated; |
| 535 | 397 | } |
| @@ -534,81 +396,8 @@ | ||
| 534 | 396 | return $updated; |
| 535 | 397 | } |
| 536 | 398 | |
| 537 | 399 | /** |
| 538 | - * Build a curated donation payload for integration hooks. | |
| 539 | - * | |
| 540 | - * Trims the raw database row to the fields advertised in the OttoKit embed | |
| 541 | - * `sample_response`, omitting internal and gateway-only columns that must not | |
| 542 | - * leave the site (ip_address, user_agent, referer_url, the admin `log`, the | |
| 543 | - * gateway `customer_id`, and the full `donation_data` submission). Monetary | |
| 544 | - * values are cast to float to match the sample the automation builder maps | |
| 545 | - * against (the raw column is a DECIMAL string). Shared by every `do_action` | |
| 546 | - * in add()/update() so no listener — OttoKit or otherwise — receives the raw | |
| 547 | - * row. | |
| 548 | - * | |
| 549 | - * Anonymous donations carry their real donor identity here. The anonymous | |
| 550 | - * checkbox is a display-only flag — the data is stored and processed as | |
| 551 | - * usual, and only the public donor wall / recent donations / top donors mask | |
| 552 | - * it. Automations that need to treat anonymous donors differently branch on | |
| 553 | - * the `is_anonymous` field in this payload; blanking the identity instead | |
| 554 | - * would silently break receipting and CRM sync for those donations. | |
| 555 | - * | |
| 556 | - * @param array<string,mixed> $donation Raw donation record from self::get(). | |
| 557 | - * @return array<string,mixed> Curated, integration-safe payload. | |
| 558 | - * @since 1.2.0 | |
| 559 | - */ | |
| 560 | - public static function get_integration_payload( $donation ) { | |
| 561 | - if ( ! is_array( $donation ) ) { | |
| 562 | - return []; | |
| 563 | - } | |
| 564 | - | |
| 565 | - $is_anonymous = ! empty( $donation['is_anonymous'] ); | |
| 566 | - | |
| 567 | - $payload = [ | |
| 568 | - 'id' => isset( $donation['id'] ) ? absint( Helper::get_string_value( $donation['id'] ) ) : 0, | |
| 569 | - 'campaign_id' => isset( $donation['campaign_id'] ) ? absint( Helper::get_string_value( $donation['campaign_id'] ) ) : 0, | |
| 570 | - 'form_id' => isset( $donation['form_id'] ) ? absint( Helper::get_string_value( $donation['form_id'] ) ) : 0, | |
| 571 | - 'donor_id' => isset( $donation['donor_id'] ) ? absint( Helper::get_string_value( $donation['donor_id'] ) ) : 0, | |
| 572 | - 'donor_name' => Helper::get_string_value( $donation['donor_name'] ?? '' ), | |
| 573 | - 'donor_email' => Helper::get_string_value( $donation['donor_email'] ?? '' ), | |
| 574 | - 'donor_phone' => Helper::get_string_value( $donation['donor_phone'] ?? '' ), | |
| 575 | - 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ), | |
| 576 | - 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ), | |
| 577 | - 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ), | |
| 578 | - 'currency' => Helper::get_string_value( $donation['currency'] ?? '' ), | |
| 579 | - 'gateway' => Helper::get_string_value( $donation['gateway'] ?? '' ), | |
| 580 | - 'payment_status' => Helper::get_string_value( $donation['payment_status'] ?? '' ), | |
| 581 | - 'payment_mode' => Helper::get_string_value( $donation['payment_mode'] ?? '' ), | |
| 582 | - 'donation_type' => Helper::get_string_value( $donation['donation_type'] ?? '' ), | |
| 583 | - 'transaction_id' => Helper::get_string_value( $donation['transaction_id'] ?? '' ), | |
| 584 | - 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ), | |
| 585 | - 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ), | |
| 586 | - 'donor_comment' => Helper::get_string_value( $donation['donor_comment'] ?? '' ), | |
| 587 | - 'is_anonymous' => $is_anonymous, | |
| 588 | - 'created_at' => Helper::get_string_value( $donation['created_at'] ?? '' ), | |
| 589 | - 'updated_at' => Helper::get_string_value( $donation['updated_at'] ?? '' ), | |
| 590 | - ]; | |
| 591 | - | |
| 592 | - /** | |
| 593 | - * Filter the curated donation payload passed to every integration hook. | |
| 594 | - * | |
| 595 | - * The payload carries the donor's real identity even for anonymous | |
| 596 | - * donations, because the anonymous checkbox only masks public donor | |
| 597 | - * lists — automations still need a usable record, and they can branch on | |
| 598 | - * the `is_anonymous` field. A site with a stricter policy (for example an | |
| 599 | - * automation that posts donor names somewhere public) can use this filter | |
| 600 | - * to blank or drop fields before they reach OttoKit or any third-party | |
| 601 | - * listener. | |
| 602 | - * | |
| 603 | - * @param array<string,mixed> $payload Curated payload. | |
| 604 | - * @param array<string,mixed> $donation Raw donation record. | |
| 605 | - * @since 1.4.0 | |
| 606 | - */ | |
| 607 | - return apply_filters( 'suredonation_integration_payload', $payload, $donation ); | |
| 608 | - } | |
| 609 | - | |
| 610 | - /** | |
| 611 | 400 | * Get a single donation by ID. |
| 612 | 401 | * |
| 613 | 402 | * @param int $donation_id Donation ID. |
| 614 | 403 | * @return array<mixed>|null Donation data or null if not found. |
| @@ -968,147 +757,8 @@ | ||
| 968 | 757 | return array_map( [ $instance, 'decode_by_datatype' ], $results ); |
| 969 | 758 | } |
| 970 | 759 | |
| 971 | 760 | /** |
| 972 | - * Build the WHERE clause + prepare-args for an export query. | |
| 973 | - * | |
| 974 | - * Always constrains to one-time donations (subscription_id = '' AND | |
| 975 | - * parent_subscription_id = 0) so recurring/renewal rows never leak into the | |
| 976 | - * free export — recurring export is Pro (see the Import & Export spec, #237). | |
| 977 | - * Optional filters: status, campaign_id, payment_mode, gateway, and a | |
| 978 | - * created_at date range (after / before). | |
| 979 | - * | |
| 980 | - * @param array<string, mixed> $filters Filter map. | |
| 981 | - * @param array<int, mixed> $args Prepare-args, populated by reference in placeholder order. | |
| 982 | - * @return string WHERE clause (without the "WHERE" keyword); placeholders only, no interpolated values. | |
| 983 | - * @since 1.3.0 | |
| 984 | - */ | |
| 985 | - private static function build_export_where( $filters, &$args ) { | |
| 986 | - $conditions = [ '1=1' ]; | |
| 987 | - | |
| 988 | - /** | |
| 989 | - * Whether the donations export is restricted to one-time donations. | |
| 990 | - * | |
| 991 | - * True by default so recurring/renewal rows never leak into the free | |
| 992 | - * export; Pro returns false to include subscriptions and renewals. | |
| 993 | - * | |
| 994 | - * @param bool $one_time_only Whether to restrict to one-time donations. | |
| 995 | - */ | |
| 996 | - if ( apply_filters( 'suredonation_export_one_time_only', true ) ) { | |
| 997 | - $conditions[] = 'subscription_id = %s'; | |
| 998 | - $conditions[] = 'parent_subscription_id = %d'; | |
| 999 | - $args[] = ''; | |
| 1000 | - $args[] = 0; | |
| 1001 | - } | |
| 1002 | - | |
| 1003 | - $status = sanitize_text_field( Helper::get_string_value( $filters['status'] ?? '' ) ); | |
| 1004 | - if ( '' !== $status && 'all' !== $status ) { | |
| 1005 | - $conditions[] = 'payment_status = %s'; | |
| 1006 | - $args[] = $status; | |
| 1007 | - } | |
| 1008 | - | |
| 1009 | - $campaign_id = absint( Helper::get_string_value( $filters['campaign_id'] ?? 0 ) ); | |
| 1010 | - if ( $campaign_id > 0 ) { | |
| 1011 | - $conditions[] = 'campaign_id = %d'; | |
| 1012 | - $args[] = $campaign_id; | |
| 1013 | - } | |
| 1014 | - | |
| 1015 | - $payment_mode = sanitize_text_field( Helper::get_string_value( $filters['payment_mode'] ?? '' ) ); | |
| 1016 | - if ( '' !== $payment_mode ) { | |
| 1017 | - $conditions[] = 'payment_mode = %s'; | |
| 1018 | - $args[] = $payment_mode; | |
| 1019 | - } | |
| 1020 | - | |
| 1021 | - $gateway = sanitize_text_field( Helper::get_string_value( $filters['gateway'] ?? '' ) ); | |
| 1022 | - if ( '' !== $gateway ) { | |
| 1023 | - $conditions[] = 'gateway = %s'; | |
| 1024 | - $args[] = $gateway; | |
| 1025 | - } | |
| 1026 | - | |
| 1027 | - $after = sanitize_text_field( Helper::get_string_value( $filters['after'] ?? '' ) ); | |
| 1028 | - if ( '' !== $after ) { | |
| 1029 | - $conditions[] = 'created_at >= %s'; | |
| 1030 | - $args[] = $after; | |
| 1031 | - } | |
| 1032 | - | |
| 1033 | - $before = sanitize_text_field( Helper::get_string_value( $filters['before'] ?? '' ) ); | |
| 1034 | - if ( '' !== $before ) { | |
| 1035 | - // A date-only `before` (Y-m-d) coerces to 00:00:00, which would | |
| 1036 | - // silently drop donations made later that same day. Normalize to | |
| 1037 | - // end-of-day so the whole end date is inclusive; full datetimes | |
| 1038 | - // are left untouched. | |
| 1039 | - if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $before ) ) { | |
| 1040 | - $before .= ' 23:59:59'; | |
| 1041 | - } | |
| 1042 | - $conditions[] = 'created_at <= %s'; | |
| 1043 | - $args[] = $before; | |
| 1044 | - } | |
| 1045 | - | |
| 1046 | - return implode( ' AND ', $conditions ); | |
| 1047 | - } | |
| 1048 | - | |
| 1049 | - /** | |
| 1050 | - * Count one-time donations matching the export filters. | |
| 1051 | - * | |
| 1052 | - * @param array<string, mixed> $filters Filter map (see build_export_where()). | |
| 1053 | - * @return int Matching row count. | |
| 1054 | - * @since 1.3.0 | |
| 1055 | - */ | |
| 1056 | - public static function count_for_export( $filters = [] ) { | |
| 1057 | - $instance = self::get_instance(); | |
| 1058 | - global $wpdb; | |
| 1059 | - $table = $instance->get_tablename(); | |
| 1060 | - | |
| 1061 | - $args = []; | |
| 1062 | - $where = self::build_export_where( $filters, $args ); | |
| 1063 | - | |
| 1064 | - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export count over live data. | |
| 1065 | - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built only from static placeholder fragments; every value is passed through prepare args. | |
| 1066 | - $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( [ $table ], $args ) ) ); | |
| 1067 | - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1068 | - | |
| 1069 | - return is_numeric( $count ) ? (int) $count : 0; | |
| 1070 | - } | |
| 1071 | - | |
| 1072 | - /** | |
| 1073 | - * Fetch one-time donations for export, decoded. | |
| 1074 | - * | |
| 1075 | - * @param array<string, mixed> $filters Filter map (see build_export_where()). | |
| 1076 | - * @param int $limit Max rows to return (0 = no limit). | |
| 1077 | - * @param int $offset Offset for pagination. | |
| 1078 | - * @return array<int, array<string, mixed>> Decoded donation rows. | |
| 1079 | - * @since 1.3.0 | |
| 1080 | - */ | |
| 1081 | - public static function get_for_export( $filters = [], $limit = 0, $offset = 0 ) { | |
| 1082 | - $instance = self::get_instance(); | |
| 1083 | - global $wpdb; | |
| 1084 | - $table = $instance->get_tablename(); | |
| 1085 | - | |
| 1086 | - $args = []; | |
| 1087 | - $where = self::build_export_where( $filters, $args ); | |
| 1088 | - | |
| 1089 | - $sql = "SELECT * FROM %i WHERE {$where} ORDER BY created_at DESC"; | |
| 1090 | - $prepare_args = array_merge( [ $table ], $args ); | |
| 1091 | - | |
| 1092 | - if ( $limit > 0 ) { | |
| 1093 | - $sql .= ' LIMIT %d, %d'; | |
| 1094 | - $prepare_args[] = absint( $offset ); | |
| 1095 | - $prepare_args[] = absint( $limit ); | |
| 1096 | - } | |
| 1097 | - | |
| 1098 | - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Export query over live data. | |
| 1099 | - // 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. | |
| 1100 | - $results = $wpdb->get_results( $wpdb->prepare( $sql, $prepare_args ), ARRAY_A ); | |
| 1101 | - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1102 | - | |
| 1103 | - if ( ! $results || ! is_array( $results ) ) { | |
| 1104 | - return []; | |
| 1105 | - } | |
| 1106 | - | |
| 1107 | - return array_map( [ $instance, 'decode_by_datatype' ], $results ); | |
| 1108 | - } | |
| 1109 | - | |
| 1110 | - /** | |
| 1111 | 761 | * Get donations by status with pagination. |
| 1112 | 762 | * |
| 1113 | 763 | * @param string $status Payment status. |
| 1114 | 764 | * @param int $limit Number of records to return. |
| @@ -1248,15 +898,13 @@ | ||
| 1248 | 898 | |
| 1249 | 899 | /** |
| 1250 | 900 | * Get donations by donor email. |
| 1251 | 901 | * |
| 1252 | - * @param string $email Donor email. | |
| 1253 | - * @param int $limit Max rows to return; 0 (default) returns all rows. | |
| 1254 | - * @param int $offset Row offset, applied only when $limit > 0. | |
| 902 | + * @param string $email Donor email. | |
| 1255 | 903 | * @return array<mixed> Array of donations. |
| 1256 | 904 | * @since 0.0.1 |
| 1257 | 905 | */ |
| 1258 | - public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) { | |
| 906 | + public static function get_by_donor_email( $email ) { | |
| 1259 | 907 | if ( empty( $email ) ) { |
| 1260 | 908 | return []; |
| 1261 | 909 | } |
| 1262 | 910 | |
| @@ -1262,35 +910,18 @@ | ||
| 1262 | 910 | |
| 1263 | 911 | $instance = self::get_instance(); |
| 1264 | 912 | global $wpdb; |
| 1265 | 913 | |
| 1266 | - $limit = max( 0, (int) $limit ); | |
| 1267 | - $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 | + ); | |
| 1268 | 923 | |
| 1269 | - if ( $limit > 0 ) { | |
| 1270 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1271 | - $results = $wpdb->get_results( | |
| 1272 | - $wpdb->prepare( | |
| 1273 | - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d', | |
| 1274 | - $instance->get_tablename(), | |
| 1275 | - sanitize_email( $email ), | |
| 1276 | - $limit, | |
| 1277 | - $offset | |
| 1278 | - ), | |
| 1279 | - ARRAY_A | |
| 1280 | - ); | |
| 1281 | - } else { | |
| 1282 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1283 | - $results = $wpdb->get_results( | |
| 1284 | - $wpdb->prepare( | |
| 1285 | - 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC', | |
| 1286 | - $instance->get_tablename(), | |
| 1287 | - sanitize_email( $email ) | |
| 1288 | - ), | |
| 1289 | - ARRAY_A | |
| 1290 | - ); | |
| 1291 | - } | |
| 1292 | - | |
| 1293 | 924 | if ( ! $results || ! is_array( $results ) ) { |
| 1294 | 925 | return []; |
| 1295 | 926 | } |
| 1296 | 927 | |
| @@ -1329,50 +960,8 @@ | ||
| 1329 | 960 | return $instance->decode_by_datatype( $result ); |
| 1330 | 961 | } |
| 1331 | 962 | |
| 1332 | 963 | /** |
| 1333 | - * Get donation by gateway subscription ID. | |
| 1334 | - * | |
| 1335 | - * Recurring handling lives in Pro, but the table (and its | |
| 1336 | - * `idx_subscription` index) belongs here, so free-side code that only needs | |
| 1337 | - * to resolve a row — such as the PayPal webhook listener recording why a | |
| 1338 | - * delivery was rejected — can look one up without depending on Pro. | |
| 1339 | - * | |
| 1340 | - * Renewals carry the same `subscription_id` as the subscription they belong | |
| 1341 | - * to, so the column is deliberately not unique. The parent row (the one with | |
| 1342 | - * no `parent_subscription_id`) is preferred and the oldest id breaks any | |
| 1343 | - * remaining tie, so the result does not depend on the query plan. | |
| 1344 | - * | |
| 1345 | - * @param string $subscription_id Gateway subscription ID. | |
| 1346 | - * @return array<string, mixed>|null Donation data or null if not found. | |
| 1347 | - * @since 1.4.0 | |
| 1348 | - */ | |
| 1349 | - public static function get_by_subscription_id( $subscription_id ) { | |
| 1350 | - if ( empty( $subscription_id ) ) { | |
| 1351 | - return null; | |
| 1352 | - } | |
| 1353 | - | |
| 1354 | - $instance = self::get_instance(); | |
| 1355 | - global $wpdb; | |
| 1356 | - | |
| 1357 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1358 | - $result = $wpdb->get_row( | |
| 1359 | - $wpdb->prepare( | |
| 1360 | - 'SELECT * FROM %i WHERE subscription_id = %s ORDER BY parent_subscription_id ASC, id ASC LIMIT 1', | |
| 1361 | - $instance->get_tablename(), | |
| 1362 | - sanitize_text_field( $subscription_id ) | |
| 1363 | - ), | |
| 1364 | - ARRAY_A | |
| 1365 | - ); | |
| 1366 | - | |
| 1367 | - if ( ! $result ) { | |
| 1368 | - return null; | |
| 1369 | - } | |
| 1370 | - | |
| 1371 | - return $instance->decode_by_datatype( $result ); | |
| 1372 | - } | |
| 1373 | - | |
| 1374 | - /** | |
| 1375 | 964 | * Get total donations count (no filters). |
| 1376 | 965 | * |
| 1377 | 966 | * @return int Total count. |
| 1378 | 967 | * @since 0.0.1 |
| @@ -1415,34 +1004,8 @@ | ||
| 1415 | 1004 | return is_numeric( $count ) ? (int) $count : 0; |
| 1416 | 1005 | } |
| 1417 | 1006 | |
| 1418 | 1007 | /** |
| 1419 | - * Get the count of completed, live-mode donations. | |
| 1420 | - * | |
| 1421 | - * Used to gate the review admin notice: a completed live donation is the | |
| 1422 | - * signal that the site has taken a genuine (non-test) donation. | |
| 1423 | - * | |
| 1424 | - * @return int Count of completed live donations. | |
| 1425 | - * @since 1.2.0 | |
| 1426 | - */ | |
| 1427 | - public static function count_live_completed() { | |
| 1428 | - $instance = self::get_instance(); | |
| 1429 | - global $wpdb; | |
| 1430 | - | |
| 1431 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1432 | - $count = $wpdb->get_var( | |
| 1433 | - $wpdb->prepare( | |
| 1434 | - 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s', | |
| 1435 | - $instance->get_tablename(), | |
| 1436 | - 'completed', | |
| 1437 | - 'live' | |
| 1438 | - ) | |
| 1439 | - ); | |
| 1440 | - | |
| 1441 | - return is_numeric( $count ) ? (int) $count : 0; | |
| 1442 | - } | |
| 1443 | - | |
| 1444 | - /** | |
| 1445 | 1008 | * Get total donations count by campaign. |
| 1446 | 1009 | * |
| 1447 | 1010 | * @param int $campaign_id Campaign ID. |
| 1448 | 1011 | * @return int Total count. |
| @@ -2002,55 +1565,8 @@ | ||
| 2002 | 1565 | } |
| 2003 | 1566 | |
| 2004 | 1567 | // Store with refund ID as key for O(1) lookup (duplicate prevention). |
| 2005 | 1568 | $donation_data['refunds'][ $refund_id ] = $refund_data; |
| 2006 | - | |
| 2007 | - // Update donation_data in database. | |
| 2008 | - $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] ); | |
| 2009 | - | |
| 2010 | - return false !== $result; | |
| 2011 | - } | |
| 2012 | - | |
| 2013 | - /** | |
| 2014 | - * Store the submitted form field values under the donation_data['fields'] key. | |
| 2015 | - * | |
| 2016 | - * The donation_data column is shared JSON (also holds refunds, notes and | |
| 2017 | - * subscription metadata), so the field data is merged under a dedicated | |
| 2018 | - * 'fields' key and never overwrites the column. | |
| 2019 | - * | |
| 2020 | - * Fields are written at donation creation (before the payment is confirmed) | |
| 2021 | - * and are intentionally retained for abandoned/failed donations — pending | |
| 2022 | - * records are legitimate business data (recovery, reconciliation, reporting). | |
| 2023 | - * There is deliberately no automatic PII purge here; erasure is handled on | |
| 2024 | - * demand via the admin delete actions (and can be wired to WordPress's | |
| 2025 | - * personal-data eraser hooks if a retention policy is later required). | |
| 2026 | - * | |
| 2027 | - * @param int $donation_id Donation ID. | |
| 2028 | - * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs. | |
| 2029 | - * @return bool True on success, false on failure. | |
| 2030 | - * @since 1.1.1 | |
| 2031 | - */ | |
| 2032 | - public static function set_submitted_fields( $donation_id, $field_data ) { | |
| 2033 | - if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) { | |
| 2034 | - return false; | |
| 2035 | - } | |
| 2036 | - | |
| 2037 | - $donation = self::get( $donation_id ); | |
| 2038 | - if ( ! $donation ) { | |
| 2039 | - return false; | |
| 2040 | - } | |
| 2041 | - | |
| 2042 | - // Get existing donation_data. | |
| 2043 | - $donation_data = $donation['donation_data'] ?? []; | |
| 2044 | - if ( is_string( $donation_data ) && ! empty( $donation_data ) ) { | |
| 2045 | - $donation_data = json_decode( $donation_data, true ); | |
| 2046 | - } | |
| 2047 | - if ( ! is_array( $donation_data ) ) { | |
| 2048 | - $donation_data = []; | |
| 2049 | - } | |
| 2050 | - | |
| 2051 | - // Merge under a dedicated key — never overwrite the shared column. | |
| 2052 | - $donation_data['fields'] = $field_data; | |
| 2053 | 1569 | |
| 2054 | 1570 | // Update donation_data in database. |
| 2055 | 1571 | $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] ); |
| 2056 | 1572 | |