HEX
Server: Apache/2.4.58 (Ubuntu)
System: Linux bsx-1-dev 6.8.0-101-generic #101-Ubuntu SMP PREEMPT_DYNAMIC Mon Feb 9 10:15:05 UTC 2026 x86_64
User: www-data (33)
PHP: 8.3.6
Disabled: NONE
Upload Files
File: /var/www/html/wp-content/plugins/memberpress/app/controllers/MeprCouponsCtrl.php
<?php

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

class MeprCouponsCtrl extends MeprCptCtrl
{
    /**
     * Load hooks for the coupons management.
     *
     * @return void
     */
    public function load_hooks()
    {
        add_filter('bulk_actions-edit-memberpresscoupon', 'MeprCouponsCtrl::disable_bulk');
        add_filter('post_row_actions', 'MeprCouponsCtrl::disable_row', 10, 2);
        add_action('admin_enqueue_scripts', 'MeprCouponsCtrl::admin_enqueue_scripts');
        add_action('manage_posts_custom_column', 'MeprCouponsCtrl::custom_columns', 10, 2);
        add_filter('manage_edit-memberpresscoupon_columns', 'MeprCouponsCtrl::columns');
        add_action('admin_init', 'MeprCoupon::expire_old_coupons_and_cleanup_db');
        add_action('save_post', 'MeprCouponsCtrl::save_postdata');
        add_action('wp_insert_post_data', 'MeprCouponsCtrl::sanitize_and_validate_coupon', 99, 2);
        add_filter('default_title', 'MeprCouponsCtrl::get_page_title_code');
        add_action('mepr_txn_store', 'MeprCouponsCtrl::update_coupon_usage_count');
        add_action('mepr_subscr_store', 'MeprCouponsCtrl::update_coupon_usage_count');

        // Cleanup list view.
        add_filter('views_edit-' . MeprCoupon::$cpt, 'MeprAppCtrl::cleanup_list_view');

        // Ajax coupon validation.
        add_action('wp_ajax_mepr_validate_coupon', 'MeprCouponsCtrl::validate_coupon_ajax');
        add_action('wp_ajax_nopriv_mepr_validate_coupon', 'MeprCouponsCtrl::validate_coupon_ajax');

        // Admin notice for coupon validation errors.
        add_action('admin_notices', 'MeprCouponsCtrl::display_coupon_validation_notice');
    }

    /**
     * Register the custom post type for coupons.
     *
     * @return void
     */
    public function register_post_type()
    {
        register_post_type(MeprCoupon::$cpt, [
            'labels'               => [
                'name'               => __('Coupons', 'memberpress'),
                'singular_name'      => __('Coupon', 'memberpress'),
                'add_new'            => __('Add New', 'memberpress'),
                'add_new_item'       => __('Add New Coupon', 'memberpress'),
                'edit_item'          => __('Edit Coupon', 'memberpress'),
                'new_item'           => __('New Coupon', 'memberpress'),
                'view_item'          => __('View Coupon', 'memberpress'),
                'search_items'       => __('Search Coupons', 'memberpress'),
                'not_found'          => __('No Coupons found', 'memberpress'),
                'not_found_in_trash' => __('No Coupons found in Trash', 'memberpress'),
                'parent_item_colon'  => __('Parent Coupon:', 'memberpress'),
            ],
            'public'               => false,
            'show_ui'              => true,
            'show_in_menu'         => 'memberpress',
            'capability_type'      => 'page',
            'hierarchical'         => false,
            'register_meta_box_cb' => 'MeprCouponsCtrl::add_meta_boxes',
            'rewrite'              => false,
            'supports'             => ['title'],
        ]);
    }

    /**
     * Define the columns for the coupon list table.
     *
     * @param  array $columns The existing columns.
     * @return array
     */
    public static function columns($columns)
    {
        $columns = [
            'cb'                 => '<input type="checkbox" />',
            'title'              => __('Code', 'memberpress'),
            'coupon-description' => __('Description', 'memberpress'),
            'date'               => __('Created', 'memberpress'),
            'coupon-discount'    => __('Discount', 'memberpress'),
            'coupon-dm'          => __('Mode', 'memberpress'),
            'coupon-starts'      => __('Starts', 'memberpress'),
            'coupon-expires'     => __('Expires', 'memberpress'),
            'coupon-count'       => __('Usage Count', 'memberpress'),
            'coupon-products'    => __('Applies To', 'memberpress'),
        ];

        return $columns;
    }

    /**
     * Render custom columns in the coupon list table.
     *
     * @param  string  $column    The name of the column.
     * @param  integer $coupon_id The ID of the coupon.
     * @return void
     */
    public static function custom_columns($column, $coupon_id)
    {
        $mepr_options = MeprOptions::fetch();
        $coupon       = new MeprCoupon($coupon_id);

        if ($coupon->ID !== null) {
            switch ($column) {
                case 'coupon-description':
                    echo esc_html(wp_strip_all_tags($coupon->post_content));
                    break;
                case 'coupon-discount':
                    if ($coupon->discount_mode === 'first-payment') {
                        echo esc_html($coupon->first_payment_discount_amount); // Update this to show proper currency symbol later.
                        echo esc_html(($coupon->first_payment_discount_type === 'percent') ? __('%', 'memberpress') : $mepr_options->currency_code);
                        echo ' → ';
                    }

                    echo esc_html($coupon->discount_amount); // Update this to show proper currency symbol later.
                    echo esc_html(($coupon->discount_type === 'percent') ? __('%', 'memberpress') : $mepr_options->currency_code);
                    break;
                case 'coupon-starts':
                    if ($coupon->post_status !== 'trash') {
                        if ($coupon->should_start) {
                                echo esc_html(MeprUtils::get_date_from_ts($coupon->starts_on));
                        } else {
                              esc_html_e('Immediately', 'memberpress');
                        }
                    } else {
                        esc_html_e('Expired', 'memberpress'); // They've moved this to trash so show it as expired.
                    }
                    break;
                case 'coupon-expires':
                    if ($coupon->post_status !== 'trash') {
                        if ($coupon->should_expire) {
                                echo esc_html(MeprUtils::get_date_from_ts($coupon->expires_on));
                        } else {
                              esc_html_e('Never', 'memberpress');
                        }
                    } else {
                        esc_html_e('Expired', 'memberpress'); // They've moved this to trash so show it as expired.
                    }
                    break;
                case 'coupon-count':
                    echo '<a href="' . esc_url(admin_url('admin.php?page=memberpress-trans&coupon_id=' . $coupon->ID)) . '">';
                    if ($coupon->usage_amount) {
                        echo esc_html((int)$coupon->usage_count . ' / ' . $coupon->usage_amount);
                    } else {
                        echo esc_html((int)$coupon->usage_count . ' / ' . __('Unlimited', 'memberpress'));
                    }
                    echo '</a>';
                    break;
                case 'coupon-dm':
                    if ($coupon->discount_mode === 'trial-override') {
                        echo esc_html(
                            sprintf(
                                // Translators: %1$s: trial days, %2$s: trial amount.
                                __('Trial: %1$s days for %2$s', 'memberpress'),
                                $coupon->trial_days,
                                MeprAppHelper::format_currency($coupon->trial_amount)
                            )
                        );
                    } elseif ($coupon->discount_mode === 'first-payment') {
                        echo esc_html_x('First Payment', 'ui', 'memberpress');
                    } elseif ($coupon->discount_mode === 'standard') {
                        echo esc_html_x('Standard', 'ui', 'memberpress');
                    } else {
                        echo esc_html_x('None', 'ui', 'memberpress');
                    }
                    break;
                case 'coupon-products':
                    echo esc_html(implode(', ', $coupon->get_formatted_products()));
            }
        }
    }

    /**
     * Add meta boxes for the coupon edit screen.
     *
     * @return void
     */
    public static function add_meta_boxes()
    {
        global $post_id;
        $c = new MeprCoupon($post_id);

        add_meta_box('memberpress-coupon-meta', __('Coupon Options', 'memberpress'), 'MeprCouponsCtrl::coupon_meta_box', MeprCoupon::$cpt, 'normal', 'high');
        add_meta_box('memberpress-coupon-description', __('Description', 'memberpress'), 'MeprCouponsCtrl::coupon_description_box', MeprCoupon::$cpt, 'normal', 'high');

        MeprHooks::do_action('mepr_coupon_meta_boxes', $c);
    }

    /**
     * Display the coupon meta box.
     *
     * @return void
     */
    public static function coupon_meta_box()
    {
        global $post_id;
        $mepr_options = MeprOptions::fetch();
        $c            = new MeprCoupon($post_id);

        MeprView::render('/admin/coupons/form', get_defined_vars());
    }

    /**
     * Display the coupon description box.
     *
     * @return void
     */
    public static function coupon_description_box()
    {
        global $post_id;
        $c = new MeprCoupon($post_id);

        ?>
    <textarea name="content" id="excerpt"><?php echo esc_textarea($c->post_content); ?></textarea>
        <?php
    }

    /**
     * Save the coupon's metadata when the post is saved.
     *
     * @param  integer $post_id The ID of the post being saved.
     * @return mixed
     */
    public static function save_postdata($post_id)
    {
        $post = get_post($post_id);

        if (!wp_verify_nonce((isset($_POST[MeprCoupon::$nonce_str])) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$nonce_str])) : '', MeprCoupon::$nonce_str . wp_salt())) {
            return $post_id; // Nonce prevents meta data from being wiped on move to trash.
        }

        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return $post_id;
        }

        if (defined('DOING_AJAX')) {
            return;
        }

        if (!empty($post) && $post->post_type === MeprCoupon::$cpt) {
            $coupon = new MeprCoupon($post_id);

            if (isset($_POST[MeprCoupon::$should_start_str])) {
                $coupon->should_start   = true;
                $month                  = isset($_POST[MeprCoupon::$starts_on_month_str]) ? max(1, intval(wp_unslash($_POST[MeprCoupon::$starts_on_month_str]))) : 1;
                $day                    = isset($_POST[MeprCoupon::$starts_on_day_str]) ? max(1, intval(wp_unslash($_POST[MeprCoupon::$starts_on_day_str]))) : 1;
                $year                   = isset($_POST[MeprCoupon::$starts_on_year_str]) ? intval(wp_unslash($_POST[MeprCoupon::$starts_on_year_str])) : 1970;
                $coupon->start_timezone = isset($_POST[MeprCoupon::$start_on_timezone_str]) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$start_on_timezone_str])) : 0;
                $coupon->starts_on      = MeprUtils::make_ts_date($month, $day, $year, true);

                if (!empty($coupon->starts_on)) {
                     $minimum_start_date = new DateTime();

                     // Get datetime object of coupon starts_on : DateTime.
                     $coupon_start_date = new DateTime();
                     $coupon_start_ts   = MeprCouponsHelper::convert_timestamp_to_tz($coupon->starts_on, $coupon->start_timezone); // Convert UTC timestamp to selected timezone timestamp.
                     $coupon_start_date->setTimestamp($coupon_start_ts);

                    if ($minimum_start_date > $coupon_start_date) {
                        $coupon->should_start = false;
                        $coupon->starts_on    = 0;
                    }
                }
            } else {
                $coupon->should_start = false;
                $coupon->starts_on    = 0;
            }

            if (isset($_POST[MeprCoupon::$should_expire_str])) {
                $coupon->should_expire   = true;
                $month                   = isset($_POST[MeprCoupon::$expires_on_month_str]) ? max(1, intval(wp_unslash($_POST[MeprCoupon::$expires_on_month_str]))) : 1;
                $day                     = isset($_POST[MeprCoupon::$expires_on_day_str]) ? max(1, intval(wp_unslash($_POST[MeprCoupon::$expires_on_day_str]))) : 1;
                $year                    = isset($_POST[MeprCoupon::$expires_on_year_str]) ? intval(wp_unslash($_POST[MeprCoupon::$expires_on_year_str])) : 1970;
                $coupon->expire_timezone = isset($_POST[MeprCoupon::$expires_on_timezone_str]) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$expires_on_timezone_str])) : 0;
                $coupon->expires_on      = MeprUtils::make_ts_date($month, $day, $year); // 23:59:59 of the chosen day
            } else {
                $coupon->should_expire = false;
                $coupon->expires_on    = 0;
            }

            if (isset($_POST[MeprCoupon::$usage_amount_str]) and is_numeric($_POST[MeprCoupon::$usage_amount_str])) {
                $coupon->usage_amount = sanitize_text_field(wp_unslash($_POST[MeprCoupon::$usage_amount_str]));
            } else {
                $coupon->usage_amount = 0;
            }

            $coupon->discount_type   = isset($_POST[MeprCoupon::$discount_type_str]) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$discount_type_str])) : 'percent';
            $coupon->discount_amount = isset($_POST[MeprCoupon::$discount_amount_str]) ? (float)sanitize_text_field(wp_unslash($_POST[MeprCoupon::$discount_amount_str])) : 0;

            if ($coupon->discount_type === 'percent' && $coupon->discount_amount > 100) {
                $coupon->discount_amount = 100; // Make sure percent is never > 100.
            }

            $coupon->first_payment_discount_type   = isset($_POST[MeprCoupon::$first_payment_discount_type_str]) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$first_payment_discount_type_str])) : 'percent';
            $coupon->first_payment_discount_amount = isset($_POST[MeprCoupon::$first_payment_discount_amount_str]) ? (float)sanitize_text_field(wp_unslash($_POST[MeprCoupon::$first_payment_discount_amount_str])) : 0;

            if ($coupon->first_payment_discount_type === 'percent' && $coupon->first_payment_discount_amount > 100) {
                $coupon->first_payment_discount_amount = 100; // Make sure percent is never > 100.
            }

            $coupon->use_on_upgrades = isset($_POST[MeprCoupon::$use_on_upgrades_str]);

            $coupon->valid_products = isset($_POST[MeprCoupon::$valid_products_str]) ? array_map('sanitize_text_field', wp_unslash($_POST[MeprCoupon::$valid_products_str])) : [];
            $coupon->discount_mode  = sanitize_text_field(wp_unslash($_POST[MeprCoupon::$discount_mode_str] ?? ''));
            $coupon->trial_days     = isset($_POST[MeprCoupon::$trial_days_str]) ? (int)sanitize_text_field(wp_unslash($_POST[MeprCoupon::$trial_days_str])) : 0;
            $coupon->trial_amount   = isset($_POST[MeprCoupon::$trial_amount_str]) ? (float) sanitize_text_field(wp_unslash($_POST[MeprCoupon::$trial_amount_str])) : 0.00;

            if (isset($_POST[MeprCoupon::$usage_per_user_count_str]) and is_numeric($_POST[MeprCoupon::$usage_per_user_count_str])) {
                $coupon->usage_per_user_count = $coupon->usage_amount <= 0 || $_POST[MeprCoupon::$usage_per_user_count_str] <= $coupon->usage_amount ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$usage_per_user_count_str])) : $coupon->usage_amount;
            } else {
                $coupon->usage_per_user_count = 0;
            }
            $coupon->usage_per_user_count_timeframe = isset($_POST[MeprCoupon::$usage_per_user_count_timeframe_str]) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$usage_per_user_count_timeframe_str])) : 'lifetime';
            $coupon->store_meta();

            MeprHooks::do_action('mepr_coupon_save_meta', $coupon);
        }
    }

    /**
     * Sanitize and validate coupon data before saving.
     *
     * Handles title sanitization, duplicate title prevention,
     * and validates that at least one membership is selected.
     *
     * @param  array $data    The post data.
     * @param  array $postarr The post array.
     * @return array
     */
    public static function sanitize_and_validate_coupon($data, $postarr)
    {
        global $wpdb;

        if ($data['post_type'] === MeprCoupon::$cpt) {
            // Get rid of invalid chars.
            $data['post_title'] = preg_replace(['/ +/', '/[^A-Za-z0-9_-]/'], ['-', ''], $data['post_title']);

            // Duplicate titles handling.
            $q1    = "SELECT ID FROM {$wpdb->posts} WHERE post_title = %s AND post_type = %s AND ID <> %d LIMIT 1";
            $q2    = $wpdb->prepare($q1, $data['post_title'], MeprCoupon::$cpt, $postarr['ID']); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $count = 0;

            if (is_admin()) {
                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
                while ($wpdb->get_var($q2)) {
                    ++$count; // Want to increment before running the query, so when we exit the loop $data['post_title'] . "-{$count}" is stil valid.
                    $q2 = $wpdb->prepare($q1, $data['post_title'] . "-{$count}", MeprCoupon::$cpt, $postarr['ID']); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                }
            }

            if ($count > 0) {
                $data['post_title'] .= "-{$count}";
            }

            // Validate that at least one membership is selected.
            // Only check when trying to publish and the nonce is present (actual form submission).
            $nonce_value = isset($_POST[MeprCoupon::$nonce_str]) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$nonce_str])) : '';
            if (wp_verify_nonce($nonce_value, MeprCoupon::$nonce_str . wp_salt())) {
                $valid_products = isset($_POST[MeprCoupon::$valid_products_str]) ? array_filter($_POST[MeprCoupon::$valid_products_str]) : [];

                if (empty($valid_products) && in_array($data['post_status'], ['publish', 'pending', 'future'], true)) {
                    // Force the coupon to draft status if no memberships are selected.
                    $data['post_status'] = 'draft';

                    // Set a transient to display an admin notice.
                    set_transient('mepr_coupon_no_products_error_' . get_current_user_id(), true, 30);
                }
            }
        }

        return $data;
    }

    /**
     * Display admin notice when coupon is saved without memberships.
     *
     * @return void
     */
    public static function display_coupon_validation_notice()
    {
        $transient_key = 'mepr_coupon_no_products_error_' . get_current_user_id();

        if (get_transient($transient_key)) {
            delete_transient($transient_key);
            ?>
            <div class="notice notice-error is-dismissible">
                <p><strong><?php esc_html_e('ERROR', 'memberpress'); ?></strong>: <?php esc_html_e('Please select at least one Membership before publishing the coupon. The coupon has been saved as a draft.', 'memberpress'); ?></p>
            </div>
            <?php
        }
    }

    /**
     * Disable certain row actions for the coupon list table.
     *
     * @param  array   $actions The existing actions.
     * @param  WP_Post $post    The current post object.
     * @return array
     */
    public static function disable_row($actions, $post)
    {
        global $current_screen;

        if (!isset($current_screen->post_type) || $current_screen->post_type !== MeprCoupon::$cpt) {
            return $actions;
        }

        unset($actions['inline hide-if-no-js']); // Hides quick-edit.
        unset($actions['delete']); // Hides permanantely delete.

        return $actions;
    }

    /**
     * Disable certain bulk actions for the coupon list table.
     *
     * @param  array $actions The existing actions.
     * @return array
     */
    public static function disable_bulk($actions)
    {
        unset($actions['delete']); // Disables permanent delete bulk action.
        unset($actions['edit']); // Disables bulk edit.

        return $actions;
    }

    /**
     * Enqueue scripts and styles for the coupon admin page.
     *
     * @param  string $hook The current admin page hook.
     * @return void
     */
    public static function admin_enqueue_scripts($hook)
    {
        global $current_screen;

        $l10n = ['mepr_no_products_message' => __('Please select at least one Membership before saving.', 'memberpress')];

        if ($current_screen->post_type === MeprCoupon::$cpt) {
            wp_register_style('mepr-settings-table-css', MEPR_CSS_URL . '/settings_table.css', [], MEPR_VERSION);
            wp_enqueue_style('mepr-coupons-css', MEPR_CSS_URL . '/admin-coupons.css', ['mepr-settings-table-css'], MEPR_VERSION);

            wp_register_script('mepr-settings-table-js', MEPR_JS_URL . '/settings_table.js', ['jquery'], MEPR_VERSION);
            wp_dequeue_script('autosave'); // Disable auto-saving.
            wp_enqueue_script('mepr-coupons-js', MEPR_JS_URL . '/admin_coupons.js', ['jquery','mepr-settings-table-js'], MEPR_VERSION);
            wp_localize_script('mepr-coupons-js', 'MeprCoupon', $l10n);

            MeprHooks::do_action('mepr_coupon_admin_enqueue_script', $hook);
        }
    }

    /**
     * Generate a random title code for the coupon page.
     *
     * @param  string $title The current title.
     * @return string
     */
    public static function get_page_title_code($title)
    {
        global $current_screen;

        if (empty($title) && isset($current_screen->post_type) && $current_screen->post_type === MeprCoupon::$cpt) {
            return MeprUtils::random_string(10, false, true);
        } else {
            return $title;
        }
    }

    /**
     * Validate the coupon via AJAX.
     *
     * @param  string|null  $code       The coupon code.
     * @param  integer|null $product_id The product ID.
     * @return void
     */
    public static function validate_coupon_ajax($code = null, $product_id = null)
    {
        check_ajax_referer('mepr_coupons', 'coupon_nonce');

        if (empty($code) || empty($product_id)) {
            if (!isset($_POST['code']) || empty($_POST['code']) || !isset($_POST['prd_id']) || empty($_POST['prd_id'])) {
                echo 'false';
                die();
            } else {
                $code       = sanitize_text_field(wp_unslash($_POST['code']));
                $product_id = sanitize_text_field(wp_unslash($_POST['prd_id']));
            }
        }

        $is_valid = MeprCoupon::is_valid_coupon_code($code, $product_id);
        $output   = MeprHooks::apply_filters('mepr_validate_coupon', $is_valid, $code, $product_id);

        if ($output) {
            echo 'true';
        } else {
            echo 'false';
        }

        die();
    }

    /**
     * Update the usage count of a coupon.
     *
     * @param  object $object The object containing the coupon ID.
     * @return void
     */
    public static function update_coupon_usage_count($object)
    {
        if (!isset($object->coupon_id) || empty($object->coupon_id)) {
            return;
        }

        $coupon = new MeprCoupon($object->coupon_id);

        if ($coupon->ID) {
            $coupon->update_usage_count();
        }
    }
}