<?php

if (!defined('ABSPATH')) {
    die('You are not allowed to call this page directly.');
}

use MemberPress\GroundLevel\Support\Time;

#[AllowDynamicProperties]
class MeprTransaction extends MeprBaseMetaModel implements MeprProductInterface, MeprTransactionInterface
{
    /**
     * INSTANCE VARIABLES & METHODS
     **/
    /**
     * Constructor for the MeprTransaction class.
     *
     * @param object|null $obj The object to initialize the transaction with.
     */
    public function __construct($obj = null)
    {
        parent::__construct($obj);
        $this->initialize(
            [
                'id'                    => 0,
                'amount'                => 0.00,
                'total'                 => 0.00,
                'tax_amount'            => 0.00,
                'tax_reversal_amount'   => 0.00,
                'tax_rate'              => 0.00,
                'tax_desc'              => '',
                'tax_class'             => 'standard',
                'user_id'               => null,
                'product_id'            => null,
                'coupon_id'             => 0,
                'trans_num'             => MeprTransaction::generate_trans_num(),
                'status'                => self::$pending_str,
                'txn_type'              => self::$payment_str,
                'gateway'               => 'manual',
                'prorated'              => null,
                'created_at'            => null,
                'expires_at'            => null, // 0 = lifetime, null = default expiration for membership
                'subscription_id'       => 0,
                'corporate_account_id'  => 0,
                'parent_transaction_id' => 0,
                'order_id'              => 0,
                'refunded_at'           => null, // Date when transaction was refunded.
            ],
            $obj
        );
    }

    // Transaction Types.
    /**
     * Payment transaction type.
     *
     * @var string
     */
    public static $payment_str = 'payment';
    /**
     * Subscription confirmation transaction type.
     *
     * @var string
     */
    public static $subscription_confirmation_str = 'subscription_confirmation';
    /**
     * Sub-account transaction type.
     *
     * @var string
     */
    public static $sub_account_str = 'sub_account';
    /**
     * WooCommerce transaction type.
     *
     * @var string
     */
    public static $woo_txn_str = 'wc_transaction';
    /**
     * Fallback transaction type.
     *
     * @var string
     */
    public static $fallback_str = 'fallback';

    // Statuses.
    /**
     * Pending transaction status.
     *
     * @var string
     */
    public static $pending_str = 'pending';
    /**
     * Failed transaction status.
     *
     * @var string
     */
    public static $failed_str = 'failed';
    /**
     * Complete transaction status.
     *
     * @var string
     */
    public static $complete_str = 'complete';
    /**
     * Confirmed transaction status.
     *
     * @var string
     */
    public static $confirmed_str = 'confirmed';
    /**
     * Refunded transaction status.
     *
     * @var string
     */
    public static $refunded_str = 'refunded';

    // Static Gateways.
    /**
     * Free gateway identifier.
     *
     * @var string
     */
    public static $free_gateway_str = 'free';
    /**
     * Manual gateway identifier.
     *
     * @var string
     */
    public static $manual_gateway_str = 'manual';
    /**
     * Fallback gateway identifier.
     *
     * @var string
     */
    public static $fallback_gateway_str = 'fallback';

    /**
     * Validate the transaction properties.
     *
     * @return void
     */
    public function validate()
    {
        $mepr_options = MeprOptions::fetch();

        $statuses = [
            self::$pending_str,
            self::$failed_str,
            self::$complete_str,
            self::$confirmed_str,
            self::$refunded_str,
        ];

        $gateways = array_merge(
            array_keys($mepr_options->integrations),
            [self::$free_gateway_str,self::$manual_gateway_str]
        );

        $this->validate_is_currency($this->amount, 0.00, null, 'amount');
        $this->validate_is_numeric($this->user_id, 1, null, 'user_id');
        $this->validate_is_numeric($this->product_id, 1, null, 'product_id');
        $this->validate_is_numeric($this->coupon_id, 0, null, 'coupon_id');
        $this->validate_not_empty($this->trans_num, 'trans_num');
        $this->validate_is_in_array($this->status, $statuses, 'status');
        $this->validate_is_in_array($this->gateway, $gateways, 'gateway');
        $this->validate_is_numeric($this->subscription_id, 0, null, 'subscription_id');
    }

    /**
     * STATIC CRUD METHODS
     **/
    /**
     * Create a new transaction record in the database.
     *
     * @param MeprTransaction $txn The transaction object to create.
     *
     * @return integer The ID of the created transaction.
     */
    public static function create($txn)
    {
        $mepr_db = new MeprDb();

        if (is_null($txn->created_at) || empty($txn->created_at)) {
            $txn->created_at = MeprUtils::ts_to_mysql_date(Time::now());
        }

        if (is_null($txn->expires_at)) {
            if ($txn->subscription_id > 0) {
                $obj = new MeprSubscription($txn->subscription_id);
            } else {
                $obj = new MeprProduct($txn->product_id);
            }

            $expires_at_ts = $obj->get_expires_at(strtotime($txn->created_at));

            if (is_null($expires_at_ts) || empty($expires_at_ts)) {
                $txn->expires_at = MeprUtils::db_lifetime();
            } else {
                $txn->expires_at = MeprUtils::ts_to_mysql_date($expires_at_ts, 'Y-m-d 23:59:59');
            }
        }

        if (is_null($txn->prorated)) {
            $prd           = new MeprProduct($txn->product_id);
            $txn->prorated = ( $prd->is_one_time_payment() && $prd->is_prorated() );
        }

        $args = (array)$txn->get_values();
        // Let the DB default these to 0000-00-00 00:00:00.
        if (empty($txn->expires_at)) {
            unset($args['expires_at']);
        }

        return MeprHooks::apply_filters('mepr_create_transaction', $mepr_db->create_record($mepr_db->transactions, $args, false), $args, $txn->user_id);
    }

    /**
     * Update an existing transaction record in the database.
     *
     * @param MeprTransaction $txn The transaction object to update.
     *
     * @return integer The ID of the updated transaction.
     */
    public static function update($txn)
    {
        $mepr_db = new MeprDb();
        $args    = (array)$txn->get_values();

        return MeprHooks::apply_filters('mepr_update_transaction', $mepr_db->update_record($mepr_db->transactions, $txn->id, $args), $args, $txn->user_id);
    }

    /**
     * Update specific fields of a transaction record in the database.
     *
     * @param integer $id   The ID of the transaction to update.
     * @param array   $args The fields to update.
     *
     * @return void
     */
    public static function update_partial($id, $args)
    {
        $mepr_db = new MeprDb();
        $mepr_db->update_record($mepr_db->transactions, $id, $args);
    }

    /**
     * Delete the transaction record from the database.
     *
     * @return boolean True on success, false on failure.
     */
    public function destroy()
    {
        $mepr_db = new MeprDb();
        $user    = $this->user();
        $id      = $this->id;
        $args    = compact('id');

        MeprHooks::do_action('mepr_txn_destroy', $this);
        MeprHooks::do_action('mepr_pre_delete_transaction', $this);
        $result = MeprHooks::apply_filters('mepr_delete_transaction', $mepr_db->delete_records($mepr_db->transactions, $args), $args);
        MeprHooks::do_action('mepr_post_delete_transaction', $id, $user, $result, $this);

        if ($user && $user->ID > 0) {
            $user->update_member_data(['txn_count', 'active_txn_count', 'memberships', 'inactive_memberships', 'expired_txn_count']);
        }

        return $result;
    }

    /*
     * Deletes all transactions associated with a specific user ID.
     * Currently disabled/unused.
     *
        public function delete_by_user_id($user_id)
        {
            $mepr_db = new MeprDb();
            $args = compact('user_id');
            return MeprHooks::apply_filters('mepr_delete_transaction', $mepr_db->delete_records($mepr_db->transactions, $args), $args);
        }
     */

    /**
     * Retrieves a transaction by its ID.
     *
     * @param  integer $id          The transaction ID.
     * @param  string  $return_type The type of object to return.
     * @return stdClass The transaction object.
     */
    public static function get_one($id, $return_type = OBJECT)
    {
        $mepr_db = new MeprDb();
        $args    = compact('id');

        return $mepr_db->get_one_record($mepr_db->transactions, $args, $return_type);
    }

    /**
     * Retrieves a transaction by its transaction number.
     *
     * @param  string $trans_num   The transaction number.
     * @param  string $return_type The type of object to return.
     * @return stdClass The transaction object.
     */
    public static function get_one_by_trans_num($trans_num, $return_type = OBJECT)
    {
        $mepr_db = new MeprDb();
        $args    = compact('trans_num');

        return $mepr_db->get_one_record($mepr_db->transactions, $args, $return_type);
    }

    /**
     * Get a transaction instance by transaction number.
     *
     * @param  string $trans_num The transaction number.
     * @return MeprTransaction|false The transaction instance or false if not found.
     */
    public static function get_instance_by_trans_num(string $trans_num)
    {
        $txn = new MeprTransaction(self::get_one_by_trans_num($trans_num, ARRAY_A));

        if ($txn->id > 0) {
            return $txn;
        }

        return false;
    }

    /**
     * Retrieve a transaction by its subscription ID.
     *
     * @param integer $subscription_id The subscription ID.
     *
     * @return stdClass|false The transaction object or false if not found.
     */
    public static function get_one_by_subscription_id($subscription_id)
    {
        if (is_null($subscription_id) || empty($subscription_id) || !$subscription_id) {
            return false;
        }

        $mepr_db = new MeprDb();
        $args    = compact('subscription_id');
        return $mepr_db->get_one_record($mepr_db->transactions, $args);
    }

    /**
     * Retrieve all transactions by their subscription ID.
     *
     * @param integer $subscription_id The subscription ID.
     *
     * @return array|false An array of transaction objects or false if none found.
     */
    public static function get_all_by_subscription_id($subscription_id)
    {
        if (is_null($subscription_id) || empty($subscription_id) || !$subscription_id) {
            return false;
        }

        $mepr_db = new MeprDb();
        $args    = compact('subscription_id');

        return $mepr_db->get_records($mepr_db->transactions, $args);
    }

    /**
     * Get all transactions with the given order ID
     *
     * @param  integer      $order_id       The order ID.
     * @param  integer|null $exclude_txn_id Optionally exclude this transaction ID.
     * @return MeprTransaction[]
     */
    public static function get_all_by_order_id($order_id, $exclude_txn_id = null)
    {
        global $wpdb;

        $transactions = [];

        if (empty($order_id)) {
            return $transactions;
        }

        $query = $wpdb->prepare("SELECT id FROM {$wpdb->mepr_transactions} WHERE order_id = %d", $order_id);

        if (is_numeric($exclude_txn_id)) {
            $query .= $wpdb->prepare(' AND id <> %d', $exclude_txn_id);
        }

        $results = $wpdb->get_col($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery

        foreach ($results as $txn_id) {
            $txn = new MeprTransaction($txn_id);

            if ($txn->id > 0) {
                $transactions[] = $txn;
            }
        }

        return $transactions;
    }

    /**
     * Get all transactions with the given order ID and gateway
     *
     * @param  integer      $order_id       The order ID.
     * @param  string       $gateway        The gateway ID.
     * @param  integer|null $exclude_txn_id Optionally exclude this transaction ID.
     * @return MeprTransaction[]
     */
    public static function get_all_by_order_id_and_gateway($order_id, $gateway, $exclude_txn_id = null)
    {
        global $wpdb;

        $transactions = [];

        if (empty($order_id)) {
            return $transactions;
        }

        $query = $wpdb->prepare("SELECT id FROM {$wpdb->mepr_transactions} WHERE order_id = %d AND gateway = %s", $order_id, $gateway);

        if (is_numeric($exclude_txn_id)) {
            $query .= $wpdb->prepare(' AND id <> %d', $exclude_txn_id);
        }

        $results = $wpdb->get_col($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery

        foreach ($results as $txn_id) {
            $txn = new MeprTransaction($txn_id);

            if ($txn->id > 0) {
                $transactions[] = $txn;
            }
        }

        return $transactions;
    }

    /**
     * Retrieve the first transaction of a subscription.
     *
     * @param integer $subscription_id The subscription ID.
     *
     * @return stdClass|false The first transaction object or false if not found.
     */
    public static function get_first_subscr_transaction($subscription_id)
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        return $wpdb->get_row(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->mepr_transactions} WHERE subscription_id=%s ORDER BY created_at LIMIT 1",
                $subscription_id
            )
        );
    }

    /**
     * Get the total count of transactions.
     *
     * @return integer The total count of transactions.
     */
    public static function get_count()
    {
        $mepr_db = new MeprDb();
        return $mepr_db->get_count($mepr_db->transactions);
    }

    /**
     * Get the count of transactions for a specific user.
     *
     * @param integer $user_id The user ID.
     *
     * @return integer The count of transactions for the user.
     */
    public static function get_count_by_user_id($user_id)
    {
        $mepr_db = new MeprDb();
        return $mepr_db->get_count($mepr_db->transactions, compact('user_id'));
    }

    /**
     * Get the count of transactions for a specific user and product.
     *
     * @param integer $user_id    The user ID.
     * @param integer $product_id The product ID.
     * @param string  $status     The transaction status.
     *
     * @return integer The count of transactions for the user and product.
     */
    public static function get_count_by_user_and_product($user_id, $product_id, $status = 'complete')
    {
        $mepr_db = new MeprDb();
        return $mepr_db->get_count($mepr_db->transactions, compact('user_id', 'product_id', 'status'));
    }

    /**
     * Retrieve all transactions.
     *
     * @param string $order_by The order by clause.
     * @param string $limit    The limit clause.
     *
     * @return array An array of transaction objects.
     */
    public static function get_all($order_by = '', $limit = '')
    {
        $mepr_db = new MeprDb();
        return $mepr_db->get_records($mepr_db->transactions, [], $order_by, $limit);
    }

    /**
     * Retrieve all transactions for a specific user.
     *
     * @param integer $user_id               The user ID.
     * @param string  $order_by              The order by clause.
     * @param string  $limit                 The limit clause.
     * @param boolean $exclude_confirmations Whether to exclude confirmation transactions.
     *
     * @return array An array of transaction objects.
     */
    public static function get_all_by_user_id($user_id, $order_by = '', $limit = '', $exclude_confirmations = false)
    {
        $mepr_db = new MeprDb();
        $args    = ['user_id' => $user_id];

        if ($exclude_confirmations) {
            $args['txn_type'] = self::$payment_str;
        }

        return $mepr_db->get_records($mepr_db->transactions, $args, $order_by, $limit);
    }

    /**
     * Retrieve all complete transactions for a specific user.
     *
     * @param integer $user_id               The user ID.
     * @param string  $order_by              The order by clause.
     * @param string  $limit                 The limit clause.
     * @param boolean $count                 Whether to return a count instead of transactions.
     * @param boolean $exclude_expired       Whether to exclude expired transactions.
     * @param boolean $include_confirmations Whether to include confirmation transactions.
     * @param boolean $include_custom_where  Whether to include custom where clauses.
     *
     * @return array|integer An array of transaction objects or the count of transactions.
     */
    public static function get_all_complete_by_user_id(
        $user_id,
        $order_by = '',
        $limit = '',
        $count = false,
        $exclude_expired = false,
        $include_confirmations = false,
        $include_custom_where = false
    ) {
        global $wpdb;

        $fields = $count ? 'COUNT(*)' : 't.*, p.post_title, m.meta_value AS access_url';

        if (!empty($order_by)) {
            $order_by = "ORDER BY {$order_by}";
        }

        if (!empty($limit)) {
            $limit = "LIMIT {$limit}";
        }

        $where = $exclude_expired ? "AND (t.expires_at > '" . MeprUtils::db_now() . "' OR t.expires_at = '" . MeprUtils::db_lifetime() . "' OR t.expires_at IS NULL) " : '';

        if ($include_confirmations) {
            // Also include sub_account transactions if there are any.
            $where .= $wpdb->prepare(
                'AND (( t.txn_type IN (%s,%s,%s,%s) AND t.status=%s ) OR ( t.txn_type=%s AND t.status=%s )) ',
                self::$payment_str,
                self::$sub_account_str,
                self::$woo_txn_str,
                self::$fallback_str,
                self::$complete_str,
                self::$subscription_confirmation_str,
                self::$confirmed_str
            );
        } else {
            $where .= $wpdb->prepare(
                'AND t.txn_type = %s AND t.status = %s ',
                self::$payment_str,
                self::$complete_str
            );
        }

        if ($include_custom_where) {
            $where .= MeprHooks::apply_filters('mepr_transaction_get_complete_by_user_id_custom_where', $where, $user_id);
        }

        $query = "SELECT {$fields}
                FROM {$wpdb->mepr_transactions} AS t
                  JOIN {$wpdb->posts} AS p
                    ON t.product_id = p.ID
                  LEFT JOIN {$wpdb->postmeta} AS m
                    ON t.product_id = m.post_id AND m.meta_key = %s
                WHERE user_id = %d
              {$where}
              {$order_by}
              {$limit}";

        if ($count) {
            return $wpdb->get_var($wpdb->prepare($query, MeprProduct::$access_url_str, $user_id)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
        } else {
            return $wpdb->get_results($wpdb->prepare($query, MeprProduct::$access_url_str, $user_id)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
        }
    }

    /**
     * Retrieve all transaction IDs for a specific user.
     *
     * @param integer $user_id  The user ID.
     * @param string  $order_by The order by clause.
     * @param string  $limit    The limit clause.
     *
     * @return array An array of transaction IDs.
     */
    public static function get_all_ids_by_user_id($user_id, $order_by = '', $limit = '')
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
        return $wpdb->get_col(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT id FROM {$wpdb->mepr_transactions} WHERE user_id=%d {$order_by}{$limit}",
                $user_id
            )
        );
    }

    /**
     * Retrieve all transaction objects for a specific user.
     *
     * @param integer $user_id  The user ID.
     * @param string  $order_by The order by clause.
     * @param string  $limit    The limit clause.
     *
     * @return array An array of transaction objects.
     */
    public static function get_all_objects_by_user_id($user_id, $order_by = '', $limit = '')
    {
        $all_records = self::get_all_by_user_id($user_id, $order_by, $limit);
        $my_objects  = [];

        foreach ($all_records as $record) {
            $my_objects[] = self::get_stored_object($record->id);
        }

        return $my_objects;
    }

    /**
     * Retrieve all transaction objects.
     *
     * @param string $order_by The order by clause.
     * @param string $limit    The limit clause.
     *
     * @return array An array of transaction objects.
     */
    public static function get_all_objects($order_by = '', $limit = '')
    {
        $all_records = self::get_all($order_by, $limit);
        $my_objects  = [];

        foreach ($all_records as $record) {
            $my_objects[] = self::get_stored_object($record->id);
        }

        return $my_objects;
    }

    /**
     * Retrieve a stored transaction object by its ID.
     *
     * @param integer $id The transaction ID.
     *
     * @return MeprTransaction The stored transaction object.
     */
    public static function get_stored_object($id)
    {
        static $my_objects;

        if (!isset($my_objects)) {
            $my_objects = [];
        }

        if (!isset($my_objects[$id]) || empty($my_objects[$id]) || !is_object($my_objects[$id])) {
            $my_objects[$id] = new MeprTransaction($id);
        }

        return $my_objects[$id];
    }

    /**
     * Store the transaction in the database.
     *
     * @param boolean $keep_expires_at_time Whether to keep the original expiration time.
     *
     * @return integer The ID of the stored transaction.
     */
    public function store($keep_expires_at_time = false)
    {
        $old_txn = new self($this->id);

        // TODO - Add real validation here.
        if ((int)$this->user_id <= 0) {
            return $this->id;
        }

        // Force 23:59:59 to help cover some overlaps.
        if (!$keep_expires_at_time && isset($this->expires_at) && !empty($this->expires_at) && $this->expires_at !== MeprUtils::db_lifetime()) {
            $this->expires_at = MeprUtils::ts_to_mysql_date(strtotime($this->expires_at), 'Y-m-d 23:59:59');
        }

        if (isset($this->id) && !is_null($this->id) && (int)$this->id > 0) {
            $this->id = self::update($this);
        } else {
            $this->id = self::create($this);
        }

        $sub = $this->subscription();
        if (
            ($this->status === self::$failed_str || $this->status === self::$refunded_str) && $sub
        ) {
            // If we have a failure or refund before the confirmation period
            // is over then we expire the subscription confirmation transaction.
            $sub->expire_confirmation_txn();
        }

        // This should happen after everything is done processing including the subscr txn_count.
        MeprHooks::do_action('mepr_txn_transition_status', $old_txn->status, $this->status, $this);
        MeprHooks::do_action('mepr_txn_store', $this, $old_txn); // 2018-03-10 BW: now including old_txn to allow for comparisons.
        MeprHooks::do_action('mepr_txn_status_' . $this->status, $this);

        return $this->id;
    }

    /**
     * This method will return an array of transactions that are or have expired.
     *
     * TODO: Modify this function and query to work for expiring trials as well.
     */
    public static function get_expiring_transactions()
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        return $wpdb->get_results(
            $wpdb->prepare(
                "
                SELECT txn.*
                FROM {$wpdb->mepr_transactions} AS txn
                LEFT JOIN {$wpdb->mepr_events} AS e
                ON e.evt_id = txn.id
                AND e.event = 'transaction-expired'
                AND e.evt_id_type = 'transactions'
                WHERE txn.status='complete'
                AND txn.user_id > 0
                AND txn.expires_at BETWEEN DATE_SUB(%s,INTERVAL 2 DAY) AND %s
                AND e.id IS NULL
                ",
                MeprUtils::db_now(),
                MeprUtils::ts_to_mysql_date(Time::now())
            )
        );
    }

    /**
     * List transactions in a table format.
     *
     * @param string  $order_by     The order by clause.
     * @param string  $order        The order direction.
     * @param string  $paged        The current page number.
     * @param string  $search       The search term.
     * @param string  $search_field The field to search in.
     * @param integer $perpage      The number of items per page.
     * @param array   $params       Additional parameters for filtering.
     *
     * @return array The list of transactions.
     */
    public static function list_table(
        $order_by = '',
        $order = '',
        $paged = '',
        $search = '',
        $search_field = 'any',
        $perpage = 10,
        $params = null
    ) {
        global $wpdb;
        $mepr_db = new MeprDb();
        if (is_null($params)) {
            $params = $_GET;
        }

        $args = [];

        $mepr_options = MeprOptions::fetch();
        $pmt_methods  = $mepr_options->payment_methods();

        if (!empty($pmt_methods)) {
            $pmt_method = '(SELECT CASE tr.gateway';

            foreach ($pmt_methods as $method) {
                $pmt_method .= $wpdb->prepare(' WHEN %s THEN %s', $method->id, "{$method->label} ({$method->name})");
            }

            $pmt_method .= $wpdb->prepare(' ELSE %s END)', __('Unknown', 'memberpress'));
        } else {
            $pmt_method = 'tr.gateway';
        }

        $cols = [
            'id'              => 'tr.id',
            'created_at'      => 'tr.created_at',
            'expires_at'      => 'tr.expires_at',
            'user_login'      => 'm.user_login',
            'user_email'      => 'm.user_email',
            'first_name'      => "(SELECT um_fname.meta_value FROM {$wpdb->usermeta} AS um_fname WHERE um_fname.user_id = m.ID AND um_fname.meta_key = 'first_name' LIMIT 1)",
            'last_name'       => "(SELECT um_lname.meta_value FROM {$wpdb->usermeta} AS um_lname WHERE um_lname.user_id = m.ID AND um_lname.meta_key = 'last_name' LIMIT 1)",
            'user_id'         => 'm.ID',
            'product_id'      => 'tr.product_id',
            'product_name'    => 'p.post_title',
            'gateway'         => $pmt_method,
            'gateway_id'      => 'tr.gateway',
            'subscr_id'       => $wpdb->prepare('IFNULL(sub.subscr_id, %s)', __('None', 'memberpress')),
            'sub_id'          => 'tr.subscription_id',
            'trans_num'       => 'tr.trans_num',
            'amount'          => 'tr.amount',
            'total'           => 'tr.total',
            'tax_amount'      => 'tr.tax_amount',
            'tax_rate'        => 'tr.tax_rate',
            'tax_class'       => 'tr.tax_class',
            'tax_desc'        => 'tr.tax_desc',
            'status'          => 'tr.status',
            'coupon_id'       => 'tr.coupon_id',
            'coupon'          => 'c.post_title',
            'order_trans_num' => 'ord.trans_num',
        ];
        $cols = MeprHooks::apply_filters('mepr_admin_transaction_list_table_columns', $cols);

        if (isset($params['month']) && is_numeric($params['month'])) {
            $args[] = $wpdb->prepare('MONTH(tr.created_at) = %s', $params['month']);
        }

        if (isset($params['day']) && is_numeric($params['day'])) {
            $args[] = $wpdb->prepare('DAY(tr.created_at) = %s', $params['day']);
        }

        if (isset($params['year']) && is_numeric($params['year'])) {
            $args[] = $wpdb->prepare('YEAR(tr.created_at) = %s', $params['year']);
        }

        if (isset($params['prd_id']) && $params['prd_id'] !== 'all' && is_numeric($params['prd_id'])) {
            $args[] = $wpdb->prepare('tr.product_id = %d', $params['prd_id']);
        }

        if (isset($params['membership']) && $params['membership'] !== 'all' && is_numeric($params['membership'])) {
            $args[] = $wpdb->prepare('tr.product_id = %d', $params['membership']);
        }

        if (isset($params['status']) && $params['status'] !== 'all') {
            $args[] = $wpdb->prepare('tr.status = %s', $params['status']);
        }

        if (isset($params['subscription']) && is_numeric($params['subscription'])) {
            $args[] = $wpdb->prepare('tr.subscription_id = %d', $params['subscription']);
        }

        if (isset($params['transaction']) && is_numeric($params['transaction'])) {
            $args[] = $wpdb->prepare('tr.id = %d', $params['transaction']);
        }

        if (isset($params['member']) && !empty($params['member'])) {
            $args[] = $wpdb->prepare('m.user_login = %s', $params['member']);
        }

        if (isset($params['gateway']) && $params['gateway'] !== 'all') {
            $args[] = $wpdb->prepare('tr.gateway = %s', $params['gateway']);
        }

        if (isset($params['coupon_id']) && !empty($params['coupon_id'])) {
            $args[] = $wpdb->prepare('tr.coupon_id = %s', $params['coupon_id']);
        }

        // Date range filtering - only apply if date_range_filter is not 'all'.
        $date_range_filter = isset($params['date_range_filter']) ? sanitize_text_field($params['date_range_filter']) : 'all';
        $date_field        = isset($params['date_field']) ? sanitize_text_field($params['date_field']) : 'created_at';

        $date_range_sql = self::build_date_filter_query($date_range_filter, $date_field, $params);
        if ('' !== $date_range_sql) {
            $args[] = $date_range_sql;
        }

        // Don't include any subscription confirmation or sub account transactions in the list table.
        if (!isset($params['include-confirmations'])) {
            $args[] = $wpdb->prepare('tr.txn_type = %s', self::$payment_str);
            $args[] = $wpdb->prepare('tr.status <> %s', self::$confirmed_str);
        }

        if (isset($params['statuses'])) {
            $qry = [];
            foreach ($params['statuses'] as $st) {
                $qry[] = $wpdb->prepare('tr.status = %s', $st);
            }

            $args[] = '(' . implode(' OR ', $qry) . ')';
        }

        $joins = [
            "/* IMPORTANT */ LEFT JOIN {$wpdb->users} AS m ON tr.user_id = m.ID",
            "/* IMPORTANT */ LEFT JOIN {$wpdb->posts} AS p ON tr.product_id = p.ID",
            "/* IMPORTANT */ LEFT JOIN {$wpdb->posts} AS c ON tr.coupon_id = c.ID",
            "/* IMPORTANT */ LEFT JOIN {$mepr_db->subscriptions} AS sub ON tr.subscription_id=sub.id",
            "/* IMPORTANT */ LEFT JOIN {$mepr_db->orders} AS ord ON tr.order_id=ord.id",
        ];

        return MeprDb::list_table($cols, "{$mepr_db->transactions} AS tr", $joins, $args, $order_by, $order, $paged, $search, $search_field, $perpage);
    }

    /**
     * Sets membership ID to 0 if for some reason a membership is deleted.
     *
     * @param integer $id The membership ID.
     */
    public static function nullify_product_id_on_delete($id)
    {
        global $wpdb, $post_type;

        if ($post_type === MeprProduct::$cpt) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            $wpdb->query(
                $wpdb->prepare("UPDATE {$wpdb->mepr_transactions} SET product_id = 0 WHERE product_id = %d", $id)
            );
        }
    }

    /**
     * Sets user ID to 0 if for some reason a user is deleted.
     *
     * @param integer $id The user ID.
     *
     * @return void
     */
    public static function nullify_user_id_on_delete($id)
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        $wpdb->query(
            $wpdb->prepare("UPDATE {$wpdb->mepr_transactions} SET user_id = 0 WHERE user_id = %d", $id)
        );
    }

    /**
     * Map a subscription status to a transaction status.
     *
     * @param string $status The subscription status.
     *
     * @return string|array|false The mapped transaction status or false if no equivalent.
     */
    public static function map_subscr_status($status)
    {
        switch ($status) {
            case MeprSubscription::$pending_str:
                return self::$pending_str;
            case MeprSubscription::$active_str:
                return [self::$complete_str, self::$confirmed_str];
            case MeprSubscription::$suspended_str:
            case MeprSubscription::$cancelled_str:
                return false; // These don't have an equivalent.
        }
    }

    /**
     * Check if the transaction is active.
     *
     * @param integer $offset The time offset for checking expiration.
     *
     * @return boolean True if the transaction is active, false otherwise.
     */
    public function is_active($offset = 0)
    {
        return ( ( $this->rec->status === self::$complete_str ||
               $this->rec->status === self::$confirmed_str ) &&
            !$this->is_expired($offset) );
    }

    /**
     * Check if the transaction is expired.
     *
     * @param integer $offset The time offset for checking expiration.
     *
     * @return boolean True if the transaction is expired, false otherwise.
     */
    public function is_expired($offset = 0)
    {
        // Check for a lifetime first.
        if (is_null($this->expires_at) || $this->expires_at === MeprUtils::db_lifetime()) {
            return false;
        }

        $todays_ts  = Time::now() + $offset; // Use the offset to check when a txn will expire.
        $expires_ts = strtotime($this->expires_at);

        return ($this->status === 'complete' && $expires_ts < $todays_ts);
    }

    /**
     * Retrieve the product associated with the transaction.
     *
     * @return MeprProduct The product object.
     */
    public function product()
    {
        // Don't do static caching stuff here.
        return MeprHooks::apply_filters('mepr_transaction_product', new MeprProduct($this->product_id), $this);
    }

    /**
     * Retrieve the group associated with the transaction.
     *
     * @return MeprGroup The group object.
     */
    public function group()
    {
        $prd = $this->product();

        return $prd->group();
    }

    /**
     * Retrieve the user associated with the transaction.
     *
     * @param boolean $force Whether to force a new user object.
     *
     * @return MeprUser The user object.
     */
    public function user($force = false)
    {
        // Don't do static caching stuff here.
        return new MeprUser($this->user_id);
    }

    /**
     * Retrieve the subscription associated with the transaction.
     *
     * @return MeprSubscription|false The subscription object or false if not found.
     */
    public function subscription()
    {
        // Don't do static caching stuff here.
        if (!isset($this->subscription_id) || empty($this->subscription_id)) {
            return false;
        }

        // For some reason when the free gateway is invoked a subscription is temporarily created
        // then stored with the txn, then deleted, this causes issues so we need to check here
        // that the $sub actually still exists.
        $sub = new MeprSubscription($this->subscription_id);

        if (!isset($sub->id) || (int)$sub->id <= 0) {
            return false;
        }

        return $sub;
    }

    /**
     * Get the order associated with this transaction
     *
     * @return MeprOrder|false
     */
    public function order()
    {
        // Don't do static caching stuff here.
        if (empty($this->order_id)) {
            return false;
        }

        $order = new MeprOrder($this->order_id);

        if ((int) $order->id <= 0) {
            return false;
        }

        return $order;
    }

    /**
     * Retrieve the coupon associated with the transaction.
     *
     * @return MeprCoupon|false The coupon object or false if not found.
     */
    public function coupon()
    {
        // Don't do static caching stuff here.
        if (!isset($this->coupon_id) || (int)$this->coupon_id <= 0) {
            return false;
        }

        $coupon = false;

        /*
         * Data-at-rest read: the stored coupon_id must stay readable in
         * brand builds that strip only the coupons feature tier, so the
         * lookup rides on the model's availability, not the module's.
         */
        if (MeprFeatures::class_usable('MeprCoupon')) {
            $found = new MeprCoupon($this->coupon_id);

            if (isset($found->ID) && $found->ID > 0) {
                $coupon = $found;
            }
        }

        /**
         * Filters the coupon resolved from the transaction's stored coupon_id.
         *
         * The default is the guarded lookup above; builds that also strip
         * the MeprCoupon model degrade to "no coupon" instead of fataling.
         *
         * @param false|MeprCoupon $coupon      The resolved coupon (false when unresolvable).
         * @param MeprTransaction  $transaction The transaction.
         */
        return MeprHooks::apply_filters('mepr_transaction_coupon', $coupon, $this);
    }

    /**
     * Retrieve the payment method associated with the transaction.
     *
     * @return MeprBaseRealGateway|false The payment method object or false if not found.
     */
    public function payment_method()
    {
        $mepr_options = MeprOptions::fetch();
        return $mepr_options->payment_method($this->gateway);
    }

    /**
     * Create a fallback transaction for the user.
     *
     * @return integer The ID of the created fallback transaction.
     */
    public function create_fallback_transaction()
    {
        $purchased_product   = $this->product();
        $group               = $purchased_product->group();
        $fallback_membership = $group->fallback_membership();
        $user                = $this->user();
        $fallback_txn        = new MeprTransaction([
            'user_id'    => $this->user_id,
            'product_id' => $fallback_membership->ID,
            'status'     => MeprTransaction::$complete_str,
            'txn_type'   => MeprTransaction::$fallback_str,
            'gateway'    => MeprTransaction::$fallback_gateway_str,
            'expires_at' => MeprUtils::db_lifetime(),
        ]);

        $fallback_txn->store();
        MeprEvent::record('transaction-completed', $fallback_txn, [
            'txn_type'   => MeprTransaction::$fallback_str,
            'user_id'    => $this->user_id,
            'product_id' => $fallback_membership->ID,
        ]);
        return $fallback_txn->id;
    }

    /**
     * Is payment required for this transaction?
     *
     * With a 100% off coupon, payment may not be required.
     *
     * @return boolean
     */
    public function is_payment_required()
    {
        $payment_required = true;

        if ($this->is_one_time_payment()) {
            if ($this->total <= 0.00) {
                $payment_required = false;
            }
        } else {
            $sub = $this->subscription();

            if ($sub instanceof MeprSubscription && $sub->total <= 0.00) {
                $payment_required = false;
            }
        }

        return $payment_required;
    }

    // Where the magic happens when creating a free transaction ... this is
    // usually called when the price of the membership has been set to zero.
    /**
     * Create a free transaction.
     *
     * @param MeprTransaction $txn       The transaction object.
     * @param boolean         $redirect  Whether to redirect the user.
     * @param string          $trans_num The transaction number.
     *
     * @return void
     */
    public static function create_free_transaction($txn, $redirect = true, $trans_num = null)
    {
        $mepr_options = MeprOptions::fetch();

        // Just short circuit if the transaction has already completed.
        if ($txn->status === self::$complete_str) {
            return;
        }

        $product = new MeprProduct($txn->product_id);

        // Expires at is now more difficult to calculate with our new membership terms.
        if ($product->period_type !== 'lifetime') { // A free recurring subscription? Nope - let's make it lifetime for free here folks.
            $expires_at = MeprUtils::db_lifetime();
        } else {
            $product_expiration = $product->get_expires_at(strtotime($txn->created_at));

            if (is_null($product_expiration)) {
                $expires_at = MeprUtils::db_lifetime();
            } else {
                $expires_at = MeprUtils::ts_to_mysql_date($product_expiration, 'Y-m-d 23:59:59');
            }
        }

        $txn->trans_num  = is_null($trans_num) ? MeprTransaction::generate_trans_num() : $trans_num;
        $txn->status     = self::$pending_str; // This needs to remain as "pending" until we've called maybe_cancel_old_subscription() below.
        $txn->txn_type   = self::$payment_str;
        $txn->gateway    = self::$free_gateway_str;
        $txn->expires_at = $expires_at;

        // This will only work before maybe_cancel_old_sub is run.
        $upgrade   = $txn->is_upgrade();
        $downgrade = $txn->is_downgrade();

        // No such thing as a free subscription in MemberPress
        // So let's clean up this mess right now.
        if (!empty($txn->subscription_id) && (int)$txn->subscription_id > 0) {
            MeprHooks::do_action('mepr_before_subscription_destroy_create_free_transaction', $txn);

            $sub = new MeprSubscription($txn->subscription_id);

            $txn->subscription_id = 0;
            $txn->store(); // Store txn here, otherwise it will get deleted during $sub->destroy().

            $sub->destroy();
        }

        // This needs to happen below the $sub destroy or maybe_cancel_old_sub() will fail
        // $txn->store(); //Force store a "pending" status.
        $event_txn   = $txn->maybe_cancel_old_sub();
        $txn->status = self::$complete_str;
        $txn->store();

        $free_gateway = new MeprBaseStaticGateway(self::$free_gateway_str, __('Free', 'memberpress'), __('Free', 'memberpress'));

        if ($upgrade) {
            $free_gateway->upgraded_sub($txn, $event_txn);
        } elseif ($downgrade) {
            $free_gateway->downgraded_sub($txn, $event_txn);
        }

        MeprUtils::send_signup_notices($txn);
        // $free_gateway->send_transaction_receipt_notices($txn); //Maybe don't need to send a receipt for a free txn
        MeprEvent::record('transaction-completed', $txn); // Delete this if we use $free_gateway->send_transaction_receipt_notices later.
        MeprEvent::record('non-recurring-transaction-completed', $txn); // Delete this if we use $free_gateway->send_transaction_receipt_notices later.

        if ($redirect) {
            $sanitized_title = sanitize_title($product->post_title);
            $query_params    = [
                'membership'    => $sanitized_title,
                'trans_num'     => $txn->trans_num,
                'membership_id' => $product->ID,
            ];

            MeprUtils::wp_redirect($mepr_options->thankyou_page_url(build_query($query_params)));
        }
    }

    /**
     * Check if the transaction is an upgrade.
     *
     * @return boolean True if the transaction is an upgrade, false otherwise.
     */
    public function is_upgrade()
    {
        return $this->is_upgrade_or_downgrade('upgrade');
    }

    /**
     * Check if the transaction is a downgrade.
     *
     * @return boolean True if the transaction is a downgrade, false otherwise.
     */
    public function is_downgrade()
    {
        return $this->is_upgrade_or_downgrade('downgrade');
    }

    /**
     * Check if the transaction is an upgrade or downgrade.
     *
     * @param string|false $type The type to check ('upgrade' or 'downgrade').
     *
     * @return boolean True if the transaction is of the specified type, false otherwise.
     */
    public function is_upgrade_or_downgrade($type = false)
    {
        $prd = $this->product();
        $usr = $this->user();

        return ($prd->is_upgrade_or_downgrade($type, $usr));
    }

    /**
     * Check if the transaction is a one-time payment.
     *
     * @return boolean True if the transaction is a one-time payment, false otherwise.
     */
    public function is_one_time_payment()
    {
        $prd = $this->product();

        return ($prd->is_one_time_payment() || !$this->subscription());
    }

    /**
     * Expire the transaction.
     *
     * @return void
     */
    public function expire()
    {
        $this->expires_at = MeprUtils::ts_to_mysql_date(Time::now() - MeprUtils::days(1));
        $this->store();
        MeprEvent::record('transaction-expired', $this, ['txn_type' => $this->txn_type]);
    }

    /**
     * Cancel the old subscription if applicable. Used by one-time payments.
     *
     * @param boolean $force_cancel_artificial Whether to force cancel artificial subscriptions.
     *
     * @return MeprTransaction|false The event transaction or false if none.
     */
    public function maybe_cancel_old_sub($force_cancel_artificial = false)
    {
        $mepr_options = MeprOptions::fetch();

        try {
            $evt_txn = false;
            if ($this->is_upgrade_or_downgrade() && $this->is_one_time_payment()) {
                $pm = $this->payment_method();
                if (!$force_cancel_artificial && $pm instanceof MeprArtificialGateway && $pm->settings->manually_complete && $pm->settings->no_cancel_up_down_grade) {
                    // If this is an artifical gateway and admin must manually approve and do not cancel when admin must manually approve
                    // then don't cancel.
                    return false;
                }

                $usr = $this->user();
                $grp = $this->group();

                $old_sub = $usr->subscription_in_group($grp->ID);
                if ($old_sub) {
                    // NOTE: This was added for one specific customer, it should only be used at customers own risk,
                    // we don not support any custom development or issues that arrise from using this hook
                    // to override the default group behavior.
                    $override_default_behavior = MeprHooks::apply_filters('mepr_override_group_default_behavior_sub', false, $old_sub);

                    if (!$override_default_behavior) {
                        $evt_txn = $old_sub->latest_txn();
                        $old_sub->expire_txns(); // Expire associated transactions for the old subscription.
                        $_REQUEST['silent'] = true; // Don't want to send cancellation notices.
                        // PT #157053195 skip cancelled subs.
                        if ($old_sub->status !== MeprSubscription::$cancelled_str) {
                            $old_sub->cancel();
                        }
                    }
                } else {
                    $old_lifetime_txn = $usr->lifetime_subscription_in_group($grp->ID);
                    if ($old_lifetime_txn && $old_lifetime_txn->id !== $this->id) {
                        // NOTE: This was added for one specific customer, it should only be used at customers own risk,
                        // we don not support any custom development or issues that arrise from using this hook
                        // to override the default group behavior.
                        $override_default_behavior = MeprHooks::apply_filters('mepr_override_group_default_behavior_lt', false, $old_lifetime_txn);

                        if (!$override_default_behavior) {
                            $old_lifetime_txn->expires_at = MeprUtils::ts_to_mysql_date(Time::now() - MeprUtils::days(1));
                            $old_lifetime_txn->store();
                            $evt_txn = $old_lifetime_txn;
                        }
                    }
                }
            }
        } catch (Exception $e) {
            // Nothing for now.
        }

        if (!empty($evt_txn)) {
            MeprHooks::do_action('mepr_changing_subscription', $this, $evt_txn);
        }

        return $evt_txn;
    }

    /**
     * Check if the transaction can perform a specific capability.
     * Convenience method to determine what we can do with the gateway associated with the transaction
     *
     * @param string $cap The capability to check.
     *
     * @return boolean True if the capability can be performed, false otherwise.
     */
    public function can($cap)
    {
        // If the status isn't complete then the refund can't happen.
        if ($cap === 'process-refunds' && $this->status !== MeprTransaction::$complete_str) {
            return false;
        }

        $pm = $this->payment_method();

        if (!($pm instanceof MeprBaseRealGateway)) {
            return false;
        }

        if ($cap === 'process-refunds' && $pm instanceof MeprAuthorizeGateway) {
            return ($pm->can($cap) &&
              ( ( $sub = $this->subscription() &&
                  !empty($sub->cc_last4) &&
                  !empty($sub->cc_exp_month) &&
                  !empty($sub->cc_exp_year) ) ||
                ( !empty($res->cc_last4) &&
                  !empty($res->cc_exp_month) &&
                  !empty($res->cc_exp_year) ) ) );
        }

        return $pm->can($cap);
    }

    /**
     * Get the number of days in the current period for the transaction.
     *
     * @return integer|string The number of days or 'lifetime' if applicable.
     */
    public function days_in_this_period()
    {
        $mepr_options = MeprOptions::fetch();

        if (is_null($this->expires_at) || $this->expires_at === MeprUtils::db_lifetime()) {
            return 'lifetime';
        }

        $time_in_this_period = (strtotime($this->expires_at) + MeprUtils::days($mepr_options->grace_expire_days)) - strtotime($this->created_at);

        return intval(round(($time_in_this_period / MeprUtils::days(1))));
    }

    /**
     * Get the number of days until the transaction expires.
     *
     * @return integer|string The number of days or 'lifetime' if applicable.
     */
    public function days_till_expiration()
    {
        $mepr_options = MeprOptions::fetch();
        $now          = Time::now();

        if (is_null($this->expires_at) || $this->expires_at === MeprUtils::db_lifetime()) {
            return 'lifetime';
        }

        $expires_at = strtotime($this->expires_at) + MeprUtils::days($mepr_options->grace_expire_days);

        if (
            $expires_at <= $now ||
            !in_array(
                $this->status,
                [
                    self::$complete_str,
                    self::$confirmed_str,
                ],
                true
            )
        ) {
            return 0;
        }

        // Round and provide an integer ... lest we screw everything up.
        return intval(round((($expires_at - $now) / MeprUtils::days(1))));
    }

    /**
     * Process a refund for the transaction.
     *
     * @return boolean True on success, false on failure.
     */
    public function refund()
    {
        if ($this->can('process-refunds')) {
            $pm = $this->payment_method();
            return $pm->process_refund($this);
        }

        return false;
    }

    /**
     * Check if a transaction exists by its transaction number.
     *
     * @param string $trans_num The transaction number.
     *
     * @return boolean True if the transaction exists, false otherwise.
     */
    public static function txn_exists($trans_num)
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        $txn_count = $wpdb->get_var(
            $wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->mepr_transactions} AS tr WHERE tr.trans_num=%s", $trans_num)
        );

        return ((int) $txn_count > 0);
    }

    /**
     * Get expired transactions.
     *
     * @return array The expired transactions.
     */
    public static function get_expired_txns()
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        return $wpdb->get_results(
            $wpdb->prepare(
                "
                SELECT tr.id, IF(tr.subscription_id = 0, 'none', sub.status) AS sub_status
                    FROM {$wpdb->mepr_transactions} AS tr
                LEFT JOIN {$wpdb->mepr_subscriptions} sub
                    ON sub.id = tr.subscription_id
                LEFT JOIN {$wpdb->mepr_events} ev
                    ON ev.evt_id = tr.id
                    AND ev.evt_id_type = 'transactions'
                    AND (ev.event = 'expired' OR ev.event = 'transaction-expired')
                WHERE tr.expires_at <> %s
                    AND tr.status IN (%s, %s)
                    AND DATE_ADD( tr.expires_at, INTERVAL 12 HOUR ) <= %s
                    AND DATE_ADD( DATE_ADD( tr.expires_at, INTERVAL 12 HOUR ), INTERVAL 2 DAY ) >= %s
                    AND ev.id IS NULL
                    AND tr.user_id > 0
                ORDER BY tr.expires_at
                ",
                MeprUtils::db_lifetime(),
                MeprTransaction::$confirmed_str,
                MeprTransaction::$complete_str,
                MeprUtils::db_now(),
                MeprUtils::db_now()
            )
        );
    }

    /**
     * This returns a count of all the transactions that are like this one
     */
    public function txn_count()
    {
        return self::get_count_by_user_and_product($this->user_id, $this->product_id, $this->status);
    }

    /**
     * Apply tax to the transaction based on the subtotal.
     *
     * @param float   $subtotal     The subtotal amount.
     * @param integer $num_decimals The number of decimal places.
     * @param float   $gross        The gross amount.
     *
     * @return void
     */
    public function apply_tax($subtotal, $num_decimals = 2, $gross = 0.00)
    {
        $usr             = $this->user();
        $prd             = $this->product();
        $calculate_taxes = get_option('mepr_calculate_taxes');

        // Now try to calculate tax info from the user info.
        if ($prd->tax_exempt) { // Don't do taxes here yo.
            list($this->amount, $this->total, $this->tax_rate, $this->tax_amount, $this->tax_desc, $this->tax_class, $this->tax_reversal_amount) = [$gross, $gross, 0.00, 0.00, '', 'standard', 0.00];
        } elseif ($calculate_taxes) {
            list($this->amount, $this->total, $this->tax_rate, $this->tax_amount, $this->tax_desc, $this->tax_class, $this->tax_reversal_amount) = $usr->calculate_tax($subtotal, $num_decimals, $prd->ID);
        } else { // If all else fails, let's blank out the tax info.
            list($this->amount, $this->total, $this->tax_rate, $this->tax_amount, $this->tax_desc, $this->tax_class, $this->tax_reversal_amount) = [$subtotal, $subtotal, 0.00, 0.00, '', 'standard', 0.00];
        }

        MeprHooks::do_action('mepr_transaction_apply_tax', $this);
    }

    /**
     * Sets up the transaction total, subtotal and tax based on a subtotal value.
     * This method also checks for inclusive vs exclusive tax.
     *
     * @param float $subtotal The subtotal amount.
     *
     * @return void
     */
    public function set_subtotal($subtotal)
    {
        $mepr_options = MeprOptions::fetch();

        if ($mepr_options->attr('tax_calc_type') === 'inclusive') {
            $usr      = $this->user();
            $subtotal = $usr->calculate_subtotal($subtotal, null, 2, $this->product());
        }

        $this->apply_tax($subtotal, 2, $subtotal);
    }

    /**
     * Load product vars.
     *
     * @param  MeprProduct $prd          The product.
     * @param  string      $cpn_code     The coupon code.
     * @param  boolean     $set_subtotal The set subtotal.
     * @return void
     */
    public function load_product_vars($prd, $cpn_code = null, $set_subtotal = false)
    {
        $mock_cpn = (object)[
            'post_title' => null,
            'ID'         => 0,
            'trial'      => 0,
        ];

        /*
         * The coupons module resolves and validates the code against the
         * product; with no listener (stripped builds) the mock coupon keeps
         * downstream pricing intact.
         */
        $cpn = empty($cpn_code)
            ? false
            : MeprHooks::apply_filters('mepr_coupon_from_code', false, $cpn_code, ['product_id' => $prd->ID]);

        if ($cpn === false) {
            $cpn = $mock_cpn;
        }

        $this->product_id = $prd->ID;
        $this->coupon_id  = $cpn->ID;

        if ($set_subtotal) {
            // The instanceof check never autoloads. mepr:coupon-surface.
            $coupon_code = $cpn instanceof MeprCoupon ? $cpn->post_title : null;
            $this->set_subtotal(MeprUtils::maybe_round_to_minimum_amount($prd->adjusted_price($coupon_code)));
        }

        MeprHooks::do_action('mepr_transaction_applied_product_vars', $this);
    }

    /**
     * Sets up the transaction total, subtotal and tax based on a gross value.
     * This will never check for tax inclusion because since it's the gross
     * kit doesn't matter (since we already know the gross amount).
     *
     * @param float $gross The gross amount.
     *
     * @return void
     */
    public function set_gross($gross)
    {
        $usr      = $this->user();
        $prd      = $this->product();
        $tax_rate = $usr->tax_rate($prd->ID);
        $subtotal = $usr->calculate_subtotal($gross, $tax_rate->reversal ? 0 : null, 2, $prd);

        $this->apply_tax($subtotal, 2, $gross);
    }

    /**
     * Get the checkout URL for the transaction.
     *
     * @param array $args Additional query arguments.
     *
     * @return string The checkout URL.
     */
    public function checkout_url($args = [])
    {
        $mepr_options = MeprOptions::fetch();
        $payment_url  = get_permalink($this->product_id);
        $delim        = MeprAppCtrl::get_param_delimiter_char($payment_url);
        $encoded_id   = urlencode(MeprUtils::base36_encode($this->id));
        $token        = urlencode($this->checkout_token());
        $payment_url  = "{$payment_url}{$delim}action=checkout&txn={$encoded_id}&mepr_checkout_token={$token}"; // Base64 encoding or something?

        $pm = $mepr_options->payment_method($this->gateway);
        if ($pm && $pm instanceof MeprBaseRealGateway && $pm->force_ssl()) {
            $payment_url = preg_replace('!^(https?:)?//!', 'https://', $payment_url);
        }

        if (count($args)) {
            $payment_url = add_query_arg($args, $payment_url);
        }

        return $payment_url;
    }

    /**
     * Get the token that authorizes the two-page checkout for this transaction,
     * generating and storing one if it doesn't exist yet.
     *
     * @return string The checkout token, or an empty string if the transaction isn't saved.
     */
    public function checkout_token()
    {
        if ((int) $this->id <= 0) {
            return '';
        }

        $token = $this->get_meta('_mepr_checkout_token', true);

        if (!is_string($token) || $token === '') {
            $token = bin2hex(random_bytes(32));
            $this->update_meta('_mepr_checkout_token', $token);
        }

        return $token;
    }

    /**
     * Verify a checkout token supplied with a request against the stored token.
     *
     * @param string $token The token supplied with the request.
     *
     * @return boolean True if the token matches the stored token.
     */
    public function verify_checkout_token($token)
    {
        if ((int) $this->id <= 0 || !is_string($token) || $token === '') {
            return false;
        }

        $stored = $this->get_meta('_mepr_checkout_token', true);

        return is_string($stored) && $stored !== '' && hash_equals($stored, $token);
    }

    /**
     * Generate a unique transaction number.
     *
     * @return string The generated transaction number.
     */
    public static function generate_trans_num()
    {
        return uniqid('mp-txn-');
    }

    /**
     * Check if the transaction is a sub-account transaction.
     *
     * @return boolean True if it is a sub-account transaction, false otherwise.
     */
    public function is_sub_account()
    {
        return ($this->txn_type === self::$sub_account_str);
    }

    /**
     * Check if the transaction is a confirmation transaction.
     *
     * @return boolean True if it is a confirmation transaction, false otherwise.
     */
    public function is_confirmation()
    {
        return ($this->txn_type === self::$subscription_confirmation_str);
    }

    /**
     * Check if the transaction is a rebill transaction.
     *
     * @return boolean True if it is a rebill transaction, false otherwise.
     */
    public function is_rebill()
    {
        $payment_index = $this->subscription_payment_index();
        return ($payment_index !== false && is_numeric($payment_index) && (int)$payment_index > 1);
    }

    /**
     * If this transaction is complete and part of a subscription then this
     * returns the number of rebills up to this current rebill--otherwise it
     * returns false.
     *
     * @return integer|false The payment index or false if not applicable.
     */
    public function subscription_payment_index()
    {
        global $wpdb;

        $status_array = [self::$complete_str,self::$refunded_str];
        if (
            $this->txn_type === self::$payment_str &&
            in_array($this->status, $status_array, true) &&
            $this->subscription_id > 0
        ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            return (int) $wpdb->get_var(
                $wpdb->prepare(
                    "
                    SELECT COUNT(*)
                        FROM {$wpdb->mepr_transactions} AS t
                    WHERE
                        (
                            (t.txn_type = %s AND t.status IN (%s, %s))
                            OR (t.status = %s AND (SELECT sub.prorated_trial
                            FROM {$wpdb->mepr_subscriptions} AS sub
                            WHERE sub.id = t.subscription_id AND sub.trial_amount = 0.00 AND sub.trial = 1) = 1)
                        )
                        AND t.subscription_id=%d
                        AND t.created_at <= %s
                    ",
                    self::$payment_str,
                    self::$complete_str,
                    self::$refunded_str,
                    self::$confirmed_str,
                    $this->subscription_id,
                    $this->created_at
                )
            );
        }

        // If this is not a subscription payment then this value is irrelevant.
        return false;
    }

    /*****
     * MAGIC METHOD HANDLERS
     *****/

    /**
     * Get the tracking subtotal for the transaction.
     * Currently only used in mepr_ecommerce_tracking shortcodes
     *
     * @param string $mgm The magic method operation.
     * @param string $val The value to set (unused).
     *
     * @return float The tracking subtotal.
     */
    protected function mgm_tracking_subtotal($mgm, $val = '')
    {
        switch ($mgm) {
            case 'get':
                if ($this->rec->txn_type === MeprTransaction::$subscription_confirmation_str) {
                    $sub = new MeprSubscription($this->rec->subscription_id);

                    if ($sub->trial) {
                        return $sub->trial_amount;
                    } else {
                        return $sub->price;
                    }
                } else {
                    return $this->rec->amount;
                }
        }
    }

    /**
     * Get the tracking total for the transaction.
     * Currently only used in mepr_ecommerce_tracking shortcodes
     *
     * @param string $mgm The magic method operation.
     * @param string $val The value to set (unused).
     *
     * @return float The tracking total.
     */
    protected function mgm_tracking_total($mgm, $val = '')
    {
        switch ($mgm) {
            case 'get':
                if ($this->rec->txn_type === MeprTransaction::$subscription_confirmation_str) {
                    $sub = new MeprSubscription($this->rec->subscription_id);

                    if ($sub->trial) {
                        return $sub->trial_total;
                    } else {
                        return $sub->total;
                    }
                } else {
                    return $this->rec->total;
                }
        }
    }

    /**
     * Get the tracking tax amount for the transaction.
     * Currently only used in mepr_ecommerce_tracking shortcodes
     *
     * @param string $mgm The magic method operation.
     * @param string $val The value to set (unused).
     *
     * @return float The tracking tax amount.
     */
    protected function mgm_tracking_tax_amount($mgm, $val = '')
    {
        switch ($mgm) {
            case 'get':
                if ($this->rec->txn_type === MeprTransaction::$subscription_confirmation_str) {
                    $sub = new MeprSubscription($this->rec->subscription_id);

                    if ($sub->trial) {
                        return $sub->trial_tax_amount;
                    } else {
                        return $sub->tax_amount;
                    }
                } else {
                    return $this->tax_amount;
                }
        }
    }

    /**
     * Get the tracking tax rate for the transaction.
     * Currently only used in mepr_ecommerce_tracking shortcodes
     *
     * @param string $mgm The magic method operation.
     * @param string $val The value to set (unused).
     *
     * @return float The tracking tax rate.
     */
    protected function mgm_tracking_tax_rate($mgm, $val = '')
    {
        switch ($mgm) {
            case 'get':
                if ($this->rec->txn_type === MeprTransaction::$subscription_confirmation_str) {
                    $sub = new MeprSubscription($this->rec->subscription_id);
                    return $sub->tax_rate;
                } else {
                    return $this->tax_rate;
                }
        }
    }

    /**
     * Get the first transaction ID for tracking purposes.
     *
     * @param string $mgm The magic method operation.
     * @param string $val The value to set (unused).
     *
     * @return integer The first transaction ID.
     */
    protected function mgm_first_txn_id($mgm, $val = '')
    {
        switch ($mgm) {
            case 'get':
                return $this->rec->id;
        }
    }

    /**
     * Get the latest transaction ID for tracking purposes.
     *
     * @param string $mgm The magic method operation.
     * @param string $val The value to set (unused).
     *
     * @return integer The latest transaction ID.
     */
    protected function mgm_latest_txn_id($mgm, $val = '')
    {
        switch ($mgm) {
            case 'get':
                return $this->rec->id;
        }
    }

    /**
     * Build SQL condition for date range filtering.
     *
     * @param  string $date_range_filter The date range filter value.
     * @param  string $date_field        The date field to filter on.
     * @param  array  $params            The parameters array containing date_start and date_end.
     * @return string Empty string if no filtering should be applied, otherwise the SQL condition.
     */
    private static function build_date_filter_query(string $date_range_filter, string $date_field, array $params): string
    {
        // Return empty string if date_range_filter is 'all' or required parameters are missing.
        if ($date_range_filter === 'all' || empty($params['date_start']) || empty($params['date_end'])) {
            return '';
        }

        global $wpdb;

        $date_field = in_array($date_field, (MeprTransactionsHelper::get_date_filter_fields(true)), true) ? $date_field : 'created_at';

        $start_date = sanitize_text_field($params['date_start']);
        $end_date   = $params['date_end'] > $start_date ? sanitize_text_field($params['date_end']) : $start_date;
        try {
            // Convert dates from site timezone to UTC for proper comparison.
            $site_timezone = MeprUtils::get_timezone();

            // Convert start date from site timezone to UTC (start of day).
            $start_datetime = new DateTime($start_date . ' 00:00:00', $site_timezone);
            $start_utc      = $start_datetime->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');

            // Convert end date from site timezone to UTC (end of day).
            $end_datetime = new DateTime($end_date . ' 23:59:59', $site_timezone);
            $end_utc      = $end_datetime->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
        } catch (Exception $e) {
            MeprUtils::debug_log('Error converting dates for transaction date filter: ' . $e->getMessage());
            return '';
        }

        return $wpdb->prepare(
            "tr.{$date_field} BETWEEN %s AND %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $start_utc,
            $end_utc
        ) ?: '';
    }
}
