PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 1.7.0.9 All 211 releases
← All changes | includes/admin/reports/class-charitable-reports.php +132 -38 1.8.12.1 → 1.8.13 View file →
@@ -1443,18 +1443,34 @@
1443 1443 $donations_by_day = array();
1444 1444 $days = $this->get_days_between_dates( $this->start_date, $this->end_date );
1445 1445 $refunded_statuses = array( 'charitable-refunded' );
1446 1446
1447 + /**
1448 + * Two grouped queries for the whole range, rather than two queries per calendar
1449 + * day. The old per-day loop made the cost a function of how long the organisation
1450 + * had existed rather than how much data it had, so a 24-year range issued ~18,000
1451 + * queries and hit max_execution_time before returning anything.
1452 + *
1453 + * The range is taken from $days rather than from $this->start_date /
1454 + * $this->end_date, because get_days_between_dates() normalises a falsy date to
1455 + * "now". Reading the bounds back off $days guarantees the query covers exactly
1456 + * the days this loop is about to render, however those dates were normalised.
1457 + */
1458 + $day_keys = array_keys( $days );
1459 + $range_start = reset( $day_keys );
1460 + $range_end = end( $day_keys );
1461 +
1462 + $completed_by_day = $campaign_donations_table->get_donations_summary_by_day_range( $range_start, $range_end, array( 'charitable-completed', 'charitable-refunded' ) );
1463 + $refunded_by_day = $campaign_donations_table->get_donations_summary_by_day_range( $range_start, $range_end, $refunded_statuses );
1464 +
1447 1465 foreach ( $days as $day_unformatted => $day_label ) {
1448 1466
1449 - $donation_today_completed = $campaign_donations_table->get_donations_summary_by_period( gmdate( 'Y-m-d', strtotime( $day_unformatted ) ), array( 'charitable-completed', 'charitable-refunded' ) );
1450 - $donation_today_refunded = $campaign_donations_table->get_donations_summary_by_period( gmdate( 'Y-m-d', strtotime( $day_unformatted ) ), $refunded_statuses );
1467 + $donation_day = gmdate( 'Y-m-d', strtotime( $day_unformatted ) );
1451 1468
1452 - $donation_day = gmdate( 'Y-m-d', strtotime( $day_unformatted ) );
1453 1469 $donations_by_day[ $donation_day ]['label'] = gmdate( 'F d, Y', strtotime( $day_unformatted ) );
1454 - $donations_by_day[ $donation_day ]['amount'] = $donation_today_completed->amount;
1470 + $donations_by_day[ $donation_day ]['amount'] = isset( $completed_by_day[ $donation_day ] ) ? $completed_by_day[ $donation_day ]->amount : 0;
1455 1471 $donations_by_day[ $donation_day ]['donors'] = 0;
1456 - $donations_by_day[ $donation_day ]['refunds'] = $donation_today_refunded->amount;
1472 + $donations_by_day[ $donation_day ]['refunds'] = isset( $refunded_by_day[ $donation_day ] ) ? $refunded_by_day[ $donation_day ]->amount : 0;
1457 1473 $donations_by_day[ $donation_day ]['net'] = 0;
1458 1474 }
1459 1475
1460 1476 // Go through the array and group the amounts and number of donors by day.
@@ -2831,50 +2847,58 @@
2831 2847
2832 2848 $refunds_by_day = array();
2833 2849 $refunded_donations_total_amount = 0;
2834 2850 $refunded_donations_total_count = 0;
2835 - $processed_donation_ids = array(); // Track unique donation IDs to avoid double-counting
2836 2851
2837 2852 $days = $this->get_days_between_dates( $this->start_date, $this->end_date );
2838 2853
2839 - // First, calculate totals from all refunded donations (regardless of day matching)
2840 - // This ensures count and amount are consistent
2854 + /**
2855 + * Bucket the refunds by day once, up front, then read from that.
2856 + *
2857 + * The day loop below used to iterate the day LABEL ('M d') rather than the
2858 + * 'Y-m-d' key, so strtotime() stamped every bucket with the CURRENT year. The
2859 + * Refunds column and Net in the breakdown table therefore read 0 for any range
2860 + * outside the current year, and for a multi-year range refunds from different
2861 + * years merged into a single month-day bucket. (The totals were already
2862 + * computed up front, so the Refunds card itself was correct.)
2863 + *
2864 + * Buckets on post_date, matching the WHERE clause above and the donation series
2865 + * in get_donations_by_day(). Bucketing on post_date_gmt while filtering on
2866 + * post_date could put a late-evening refund in a day outside the range on a
2867 + * non-UTC site.
2868 + *
2869 + * The GROUP BY p.ID in the query already guarantees one row per donation, so no
2870 + * per-day dedup is needed. The previous "$donation_id > 0" guard also silently
2871 + * dropped a refunded donation that has no campaign_donations row from the totals;
2872 + * such a refund is now counted, with a zero amount, so it stays visible.
2873 + */
2874 + $refunds_by_date = array();
2875 +
2841 2876 if ( ! empty( $refunded_donations ) ) {
2842 2877 foreach ( $refunded_donations as $refunded_donation ) {
2843 - // Only count each unique donation ID once
2844 - $donation_id = isset( $refunded_donation->donation_id ) ? intval( $refunded_donation->donation_id ) : 0;
2845 - if ( $donation_id > 0 && ! in_array( $donation_id, $processed_donation_ids, true ) ) {
2846 - $refunded_donations_total_amount += floatval( $refunded_donation->amount );
2847 - $refunded_donations_total_count++;
2848 - $processed_donation_ids[] = $donation_id;
2878 + $refund_day = gmdate( 'Y-m-d', strtotime( $refunded_donation->post_date ) );
2879 +
2880 + if ( ! isset( $refunds_by_date[ $refund_day ] ) ) {
2881 + $refunds_by_date[ $refund_day ] = array(
2882 + 'amount' => 0,
2883 + 'count' => 0,
2884 + );
2849 2885 }
2886 +
2887 + $refunds_by_date[ $refund_day ]['amount'] += floatval( $refunded_donation->amount );
2888 + ++$refunds_by_date[ $refund_day ]['count'];
2889 +
2890 + $refunded_donations_total_amount += floatval( $refunded_donation->amount );
2891 + ++$refunded_donations_total_count;
2850 2892 }
2851 2893 }
2852 2894
2853 - // Then, organize by day for the breakdown
2854 - foreach ( $days as $day ) {
2855 - $donation_day = gmdate( 'Y-m-d', strtotime( $day ) );
2856 - $refunded_donation_amount = 0;
2857 - $refunded_donation_count = 0;
2858 - $processed_day_donation_ids = array(); // Track unique donation IDs per day
2859 - if ( ! empty( $refunded_donations ) ) :
2860 - foreach ( $refunded_donations as $refunded_donation ) {
2861 - // if the refunded donation post date is the same day as the donation_day, add it to the refunded total.
2862 - $refund_date = gmdate( 'Y-m-d', strtotime( $refunded_donation->post_date_gmt ) );
2863 - if ( $refund_date === $donation_day ) {
2864 - $donation_id = isset( $refunded_donation->donation_id ) ? intval( $refunded_donation->donation_id ) : 0;
2865 - // Only count each unique donation ID once per day
2866 - if ( $donation_id > 0 && ! in_array( $donation_id, $processed_day_donation_ids, true ) ) {
2867 - $refunded_donation_amount += floatval( $refunded_donation->amount );
2868 - ++$refunded_donation_count;
2869 - $processed_day_donation_ids[] = $donation_id;
2870 - }
2871 - }
2872 - }
2873 - endif;
2874 - $refunds_by_day[ $donation_day ]['label'] = gmdate( 'F d, Y', strtotime( $day ) );
2875 - $refunds_by_day[ $donation_day ]['amount'] = $refunded_donation_amount;
2876 - $refunds_by_day[ $donation_day ]['donors'] = $refunded_donation_count;
2895 + foreach ( $days as $day_unformatted => $day_label ) {
2896 + $donation_day = gmdate( 'Y-m-d', strtotime( $day_unformatted ) );
2897 +
2898 + $refunds_by_day[ $donation_day ]['label'] = gmdate( 'F d, Y', strtotime( $day_unformatted ) );
2899 + $refunds_by_day[ $donation_day ]['amount'] = isset( $refunds_by_date[ $donation_day ] ) ? $refunds_by_date[ $donation_day ]['amount'] : 0;
2900 + $refunds_by_day[ $donation_day ]['donors'] = isset( $refunds_by_date[ $donation_day ] ) ? $refunds_by_date[ $donation_day ]['count'] : 0;
2877 2901 }
2878 2902
2879 2903 return array(
2880 2904 'donations' => $refunded_donations,
@@ -3605,8 +3629,69 @@
3605 3629 return false;
3606 3630 }
3607 3631
3608 3632 /**
3633 + * Get the date of the earliest donation on the site.
3634 + *
3635 + * Used to build the "All Time" preset in the reporting date picker, so that
3636 + * lifetime figures are one click away rather than a hand-typed range.
3637 + *
3638 + * Every valid donation status is considered, not just completed ones, because
3639 + * the reports can be filtered by status and a narrower floor would hide the
3640 + * earliest refunded or cancelled donation.
3641 + *
3642 + * @since 1.8.12.4
3643 + *
3644 + * @return string Date in Y/m/d format. Falls back to one month ago when the
3645 + * site has no donations yet.
3646 + */
3647 + public function get_earliest_donation_date() {
3648 +
3649 + $cached = get_transient( 'charitable-reports-earliest-donation-date' );
3650 +
3651 + if ( false !== $cached ) {
3652 + return $cached;
3653 + }
3654 +
3655 + global $wpdb;
3656 +
3657 + $statuses = array_keys( charitable_get_valid_donation_statuses() );
3658 +
3659 + // `charitable_donation_statuses` is a public filter, and an empty list would
3660 + // build `IN ( )`. Nothing to measure in that case, so use the fallback.
3661 + if ( empty( $statuses ) ) {
3662 + return gmdate( 'Y/m/d', strtotime( '-1 month' ) );
3663 + }
3664 +
3665 + $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
3666 +
3667 + $earliest = $wpdb->get_var( // phpcs:ignore
3668 + $wpdb->prepare(
3669 + "SELECT MIN( post_date ) FROM {$wpdb->posts}
3670 + WHERE post_type = 'donation'
3671 + AND post_status IN ( {$placeholders} )", // phpcs:ignore
3672 + $statuses
3673 + )
3674 + );
3675 +
3676 + if ( empty( $earliest ) || '0000-00-00 00:00:00' === $earliest ) {
3677 + /*
3678 + * Deliberately not cached. A site with no donations today may be one
3679 + * import away from four years of history, and a cached fallback would
3680 + * leave "All Time" starting a month ago for a full day afterwards -
3681 + * which is the exact wrong-figure trap this preset exists to close.
3682 + */
3683 + return gmdate( 'Y/m/d', strtotime( '-1 month' ) );
3684 + }
3685 +
3686 + $date = gmdate( 'Y/m/d', strtotime( $earliest ) );
3687 +
3688 + set_transient( 'charitable-reports-earliest-donation-date', $date, DAY_IN_SECONDS );
3689 +
3690 + return $date;
3691 + }
3692 +
3693 + /**
3609 3694 * Get localized strings.
3610 3695 *
3611 3696 * @since 1.8.1
3612 3697 *
@@ -3665,9 +3750,18 @@
3665 3750 'payment_keys' => (array) array_reverse( $this->payment_keys ),
3666 3751 ),
3667 3752 'default_start_date' => current_time( gmdate( 'Y-m-d', strtotime( '-1 month' ) ) ),
3668 3753 'default_end_date' => current_time( gmdate( 'Y-m-d' ) ),
3754 + 'earliest_donation_date' => $this->get_earliest_donation_date(),
3755 + // The date picker uses each preset's label as its object key, so the
3756 + // label has to travel to JS rather than being written there. It rides
3757 + // this same array as `earliest_donation_date` on purpose: the
3758 + // reporting JS contains no wp.i18n calls, so a second localization
3759 + // mechanism for one string would be the inconsistent choice.
3760 + 'all_time_label' => __( 'All Time', 'charitable' ),
3669 3761 'advanced_reports' => $charitable_reports_advanced_report_strings,
3762 + 'i18n_report_timeout' => __( 'This report took too long to load and was stopped by the server. The figures have been cleared so they are not mistaken for the range you asked for. Try a shorter date range.', 'charitable' ),
3763 + 'i18n_report_error' => __( 'This report could not be loaded. The figures have been cleared so they are not mistaken for the range you asked for. Please try again.', 'charitable' ),
3670 3764 );
3671 3765
3672 3766 $strings = apply_filters( 'charitable_reporting_strings', $strings );
3673 3767