'afridirect_payment', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, // No non-meta way to count standalone payments by their status. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => '_afridirect_status', 'value' => $status, ), ), ) ); $counts[ $status ] = (int) $query->found_posts; } return $counts; } /** * Number of WooCommerce orders in each state handled by the gateway. * * @return int[] Keys: review, pending, approved, rejected. */ public static function woo_counts() { $counts = array( 'review' => 0, 'pending' => 0, 'approved' => 0, 'rejected' => 0, ); if ( ! class_exists( 'WooCommerce' ) || ! function_exists( 'wc_orders_count' ) ) { return $counts; } $counts['review'] = (int) wc_orders_count( 'adp-proof' ); $counts['pending'] = (int) wc_orders_count( 'adp-pending' ); $counts['approved'] = self::woo_review_count( 'approved' ); $counts['rejected'] = self::woo_review_count( 'declined' ); return $counts; } /** * Count gateway orders by the review decision stored on the order. * * @param string $decision approved|declined. * @return int */ private static function woo_review_count( $decision ) { $result = wc_get_orders( array( 'limit' => 1, 'paginate' => true, 'return' => 'ids', 'payment_method' => 'afridirect_payment', // No non-meta way to find orders by their review decision. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => '_afridirect_review_status', 'value' => $decision, ), ), ) ); return ( is_object( $result ) && isset( $result->total ) ) ? (int) $result->total : 0; } /** * Combined totals across standalone payments and WooCommerce orders. * * @return int[] Keys: review, pending, approved, rejected. */ public static function get_stats() { $standalone = self::standalone_counts(); $woo = self::woo_counts(); return array( 'review' => $standalone['needs_review'] + $woo['review'], 'pending' => $standalone['pending_proof'] + $woo['pending'], 'approved' => $standalone['approved'] + $woo['approved'], 'rejected' => $standalone['rejected'] + $woo['rejected'], ); } /** * Date ranges the money summary can be filtered by. * * @return string[] Range key => label. */ public static function get_range_options() { return array( 'all' => __( 'All time', 'afridirect-payment' ), 'month' => __( 'This month', 'afridirect-payment' ), '30d' => __( 'Last 30 days', 'afridirect-payment' ), '7d' => __( 'Last 7 days', 'afridirect-payment' ), ); } /** * Start of a date range, as a GMT datetime string. * * @param string $range Range key from get_range_options(). * @return string */ private static function get_range_start( $range ) { switch ( $range ) { case 'month': $local = wp_date( 'Y-m-01 00:00:00' ); break; case '30d': $local = wp_date( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ); break; case '7d': $local = wp_date( 'Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS ); break; default: return '1970-01-01 00:00:00'; } return get_gmt_from_date( $local ); } /** * The currency the store itself uses, if WooCommerce provides one. * * @return string Upper-case currency code, or an empty string. */ private static function store_currency() { return function_exists( 'get_woocommerce_currency' ) ? strtoupper( (string) get_woocommerce_currency() ) : ''; } /** * Payment counts and amounts grouped by currency and bucket. * * Buckets: confirmed (approved), pending (proof uploaded, waiting for a decision) * and awaiting (no proof yet). Rejected and expired payments are left out. * * @param string $since GMT datetime; only payments created at or after it are counted. * @return array Currency => bucket => array( 'count' => int, 'total' => float ). */ private static function get_money_data( $since ) { $cache_key = 'money_' . md5( $since ); $cached = wp_cache_get( $cache_key, 'afridirect_payment' ); if ( false !== $cached ) { return $cached; } global $wpdb; // Aggregating amounts needs SQL: the WP and WooCommerce APIs can only load records one by one, // which is too slow to total a large store. Results are cached for the request. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $standalone = $wpdb->get_results( $wpdb->prepare( "SELECT cur.meta_value AS currency, CASE st.meta_value WHEN 'approved' THEN 'confirmed' WHEN 'needs_review' THEN 'pending' WHEN 'pending_proof' THEN 'awaiting' ELSE '' END AS bucket, COUNT(*) AS cnt, SUM(CAST(amt.meta_value AS DECIMAL(20,4))) AS total FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_afridirect_status' LEFT JOIN {$wpdb->postmeta} cur ON cur.post_id = p.ID AND cur.meta_key = '_afridirect_currency' LEFT JOIN {$wpdb->postmeta} amt ON amt.post_id = p.ID AND amt.meta_key = '_afridirect_amount' WHERE p.post_type = 'afridirect_payment' AND p.post_status = 'publish' AND p.post_date_gmt >= %s GROUP BY cur.meta_value, bucket", $since ), ARRAY_A ); $all_rows = is_array( $standalone ) ? $standalone : array(); if ( class_exists( 'WooCommerce' ) ) { $hpos = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); if ( $hpos ) { $woo = $wpdb->get_results( $wpdb->prepare( "SELECT o.currency AS currency, CASE WHEN rv.meta_value = 'approved' AND o.status NOT IN ( 'wc-refunded', 'wc-cancelled', 'trash' ) THEN 'confirmed' WHEN o.status = 'wc-adp-proof' THEN 'pending' WHEN o.status = 'wc-adp-pending' THEN 'awaiting' ELSE '' END AS bucket, COUNT(*) AS cnt, SUM(o.total_amount) AS total FROM {$wpdb->prefix}wc_orders o LEFT JOIN {$wpdb->prefix}wc_orders_meta rv ON rv.order_id = o.id AND rv.meta_key = '_afridirect_review_status' WHERE o.type = 'shop_order' AND o.payment_method = 'afridirect_payment' AND o.date_created_gmt >= %s GROUP BY o.currency, bucket", $since ), ARRAY_A ); } else { $woo = $wpdb->get_results( $wpdb->prepare( "SELECT cur.meta_value AS currency, CASE WHEN rv.meta_value = 'approved' AND p.post_status NOT IN ( 'wc-refunded', 'wc-cancelled', 'trash' ) THEN 'confirmed' WHEN p.post_status = 'wc-adp-proof' THEN 'pending' WHEN p.post_status = 'wc-adp-pending' THEN 'awaiting' ELSE '' END AS bucket, COUNT(*) AS cnt, SUM(CAST(tot.meta_value AS DECIMAL(20,4))) AS total FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_payment_method' AND pm.meta_value = 'afridirect_payment' LEFT JOIN {$wpdb->postmeta} cur ON cur.post_id = p.ID AND cur.meta_key = '_order_currency' LEFT JOIN {$wpdb->postmeta} tot ON tot.post_id = p.ID AND tot.meta_key = '_order_total' LEFT JOIN {$wpdb->postmeta} rv ON rv.post_id = p.ID AND rv.meta_key = '_afridirect_review_status' WHERE p.post_type = 'shop_order' AND p.post_date_gmt >= %s GROUP BY cur.meta_value, bucket", $since ), ARRAY_A ); } if ( is_array( $woo ) ) { $all_rows = array_merge( $all_rows, $woo ); } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $fallback = self::store_currency(); $fallback = '' !== $fallback ? $fallback : 'USD'; $data = array(); foreach ( $all_rows as $row ) { if ( '' === $row['bucket'] ) { continue; } $currency = strtoupper( trim( (string) $row['currency'] ) ); $currency = '' !== $currency ? $currency : $fallback; if ( ! isset( $data[ $currency ][ $row['bucket'] ] ) ) { $data[ $currency ][ $row['bucket'] ] = array( 'count' => 0, 'total' => 0.0, ); } $data[ $currency ][ $row['bucket'] ]['count'] += (int) $row['cnt']; $data[ $currency ][ $row['bucket'] ]['total'] += (float) $row['total']; } wp_cache_set( $cache_key, $data, 'afridirect_payment', MINUTE_IN_SECONDS ); return $data; } /** * Money summary for one currency over a date range. * * Amounts in different currencies are never added together; the caller picks one currency. * * @param string $range Range key from get_range_options(). * @param string $currency Requested currency code; falls back to a sensible default. * @return array Keys: currencies (codes, busiest first), currency, confirmed, pending (each array( count, total )), payments (int). */ public static function get_money_summary( $range = 'all', $currency = '' ) { $data = self::get_money_data( self::get_range_start( $range ) ); // Busiest currency first. $activity = array(); foreach ( $data as $code => $buckets ) { $activity[ $code ] = 0; foreach ( $buckets as $bucket ) { $activity[ $code ] += $bucket['count']; } } arsort( $activity ); $currencies = array_keys( $activity ); $store = self::store_currency(); if ( '' === $currency || ! isset( $data[ $currency ] ) ) { if ( '' !== $store && isset( $data[ $store ] ) ) { $currency = $store; } elseif ( ! empty( $currencies ) ) { $currency = $currencies[0]; } else { $currency = '' !== $store ? $store : 'USD'; } } $buckets = isset( $data[ $currency ] ) ? $data[ $currency ] : array(); $empty = array( 'count' => 0, 'total' => 0.0, ); $payments = 0; foreach ( $buckets as $bucket ) { $payments += $bucket['count']; } return array( 'currencies' => $currencies, 'currency' => $currency, 'confirmed' => isset( $buckets['confirmed'] ) ? $buckets['confirmed'] : $empty, 'pending' => isset( $buckets['pending'] ) ? $buckets['pending'] : $empty, 'payments' => $payments, ); } /** * Format an amount in a currency for display. * * @param float $amount Amount. * @param string $currency Currency code. * @return string HTML-safe string. */ public static function format_money( $amount, $currency ) { if ( function_exists( 'wc_price' ) ) { return wp_kses_post( wc_price( $amount, array( 'currency' => $currency ) ) ); } return esc_html( number_format_i18n( $amount, 2 ) . ' ' . $currency ); } /** * Most recent payments waiting for a decision, newest first. * * @param int $limit Maximum number of items. * @return array[] */ public static function get_review_queue( $limit = 5 ) { $items = array(); $query = new WP_Query( array( 'post_type' => 'afridirect_payment', 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, 'update_post_term_cache' => false, // No non-meta way to list standalone payments by status. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => '_afridirect_status', 'value' => 'needs_review', ), ), ) ); foreach ( $query->posts as $post ) { $payment = AfriDirect_Payments::get_payment( $post->ID ); if ( ! $payment ) { continue; } $items[] = array( 'kind' => __( 'Standalone', 'afridirect-payment' ), 'title' => $payment['reference'], 'payer' => $payment['payer_name'] ? $payment['payer_name'] : $payment['payer_email'], 'method' => $payment['method_label'], 'amount_html' => esc_html( number_format_i18n( $payment['amount'], 2 ) . ' ' . strtoupper( $payment['currency'] ) ), 'time' => (int) get_post_time( 'U', true, $post ), 'url' => get_edit_post_link( $post->ID, 'raw' ), ); } if ( class_exists( 'WooCommerce' ) && function_exists( 'wc_get_orders' ) ) { $orders = wc_get_orders( array( 'status' => 'adp-proof', 'limit' => $limit, 'orderby' => 'date', 'order' => 'DESC', ) ); foreach ( $orders as $order ) { $created = $order->get_date_created(); $payer = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ); $items[] = array( 'kind' => __( 'WooCommerce', 'afridirect-payment' ), /* translators: %s: WooCommerce order number. */ 'title' => sprintf( __( 'Order #%s', 'afridirect-payment' ), $order->get_order_number() ), 'payer' => $payer ? $payer : $order->get_billing_email(), 'method' => $order->get_meta( '_afridirect_method_label' ), 'amount_html' => wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ), 'time' => $created ? $created->getTimestamp() : 0, 'url' => AfriDirect_Payment_Admin_UI::page_url( 'afridirect-woo-payments' ), ); } } usort( $items, static function ( $a, $b ) { return $b['time'] <=> $a['time']; } ); return array_slice( $items, 0, $limit ); } /** * Published payment forms, for the shortcode helper. * * @param int $limit Maximum number of forms. * @return WP_Post[] */ public static function get_forms( $limit = 5 ) { return get_posts( array( 'post_type' => 'afridirect_form', 'post_status' => 'publish', 'numberposts' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => false, ) ); } /** * Whether the WooCommerce gateway is switched on. * * @return bool */ public static function gateway_enabled() { if ( ! class_exists( 'WooCommerce' ) ) { return false; } $settings = get_option( 'woocommerce_afridirect_payment_settings', array() ); return is_array( $settings ) && isset( $settings['enabled'] ) && 'yes' === $settings['enabled']; } /** * Whether a payment form shortcode is already published on a page or post, * or a standalone payment has been received (which proves a form is live). * * @return bool */ private static function shortcode_in_use() { $counts = wp_count_posts( 'afridirect_payment' ); if ( $counts && ! empty( $counts->publish ) ) { return true; } $found = get_posts( array( 'post_type' => array( 'page', 'post' ), 'post_status' => 'publish', 's' => '[afridirect_form', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, ) ); return ! empty( $found ); } /** * Setup checklist, computed from the site's real configuration. * * @return array[] Each step: title, desc, done, ctas (label/url pairs). */ public static function get_setup_steps() { $has_methods = ! empty( AfriDirect_Payment_Methods::get_all() ); $form_counts = wp_count_posts( 'afridirect_form' ); $has_forms = $form_counts && ! empty( $form_counts->publish ); $woo = class_exists( 'WooCommerce' ); $is_live = self::gateway_enabled() || self::shortcode_in_use(); $live_ctas = array(); if ( $has_forms ) { $live_ctas[] = array( 'label' => __( 'Get the shortcode', 'afridirect-payment' ), 'url' => '#afd-forms', ); } if ( $woo ) { $live_ctas[] = array( 'label' => __( 'Enable at checkout', 'afridirect-payment' ), 'url' => AfriDirect_Payment_Admin_UI::page_url( 'afridirect-settings' ), ); } return array( array( 'title' => __( 'Add a payment method', 'afridirect-payment' ), 'desc' => __( 'Where customers send money: a bank account, Zelle, PayPal, crypto wallet or anything custom.', 'afridirect-payment' ), 'done' => $has_methods, 'ctas' => array( array( 'label' => __( 'Add method', 'afridirect-payment' ), 'url' => AfriDirect_Payment_Admin_UI::page_url( 'afridirect-payment-methods' ), ), ), ), array( 'title' => __( 'Create a payment form', 'afridirect-payment' ), 'desc' => __( 'Choose which methods a form offers, the amount and the time customers have to upload proof.', 'afridirect-payment' ), 'done' => $has_forms, 'ctas' => array( array( 'label' => __( 'Create form', 'afridirect-payment' ), 'url' => admin_url( 'post-new.php?post_type=afridirect_form' ), ), ), ), array( 'title' => __( 'Go live', 'afridirect-payment' ), 'desc' => $woo ? __( 'Paste the form shortcode on any page, or turn on the gateway so customers can pay this way at checkout.', 'afridirect-payment' ) : __( 'Paste the form shortcode on any page of your site.', 'afridirect-payment' ), 'done' => $is_live, 'ctas' => $live_ctas, ), ); } }