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/MeprRemindersCtrl.php
<?php

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

class MeprRemindersCtrl extends MeprCptCtrl
{
    /**
     * Load hooks for managing reminders and cron jobs.
     *
     * @return void
     */
    public function load_hooks()
    {
        add_action('admin_enqueue_scripts', [$this, 'enqueue_scripts']);
        add_action('save_post', [$this, 'save_postdata']);

        $disable_reminder_crons = get_option('mepr_disable_reminder_crons');
        if (!$disable_reminder_crons) {
            $r = new MeprReminder();
            foreach ($r->event_actions as $e) {
                add_action($e, [$this, 'send_reminders']);
            }

            // Set up cron schedules.
            add_filter('cron_schedules', [$this, 'intervals']);
            add_action('mepr_reminders_worker', [$this, 'worker']);
            add_action('init', [$this, 'schedule_reminders']);
        } else {
            $this->unschedule_reminders();
        }

        // Clean up crons and possibly other stuff when a reminder is deleted or trashed.
        add_action('before_delete_post', [$this, 'delete']);
        add_action('wp_trash_post', [$this, 'delete']);

        // Add some cols.
        add_action('manage_posts_custom_column', [$this, 'custom_columns'], 10, 2);
        add_filter('manage_edit-mp-reminder_columns', [$this, 'columns']);
    }

    /**
     * Schedule a reminder for a specific ID.
     *
     * @param integer $id The ID of the reminder to schedule.
     *
     * @return void
     */
    public function schedule_reminder($id)
    {
        // Stop zombie cron jobs in their tracks here.
        $reminder = $this->get_valid_reminder($id);

        if ($reminder === false) {
            $this->unschedule_reminder($id);
            return;
        }

        $args = [$id];

        if (!wp_next_scheduled('mepr_reminders_worker', $args)) {
            wp_schedule_event(
                time(),
                'mepr_reminders_worker_interval',
                'mepr_reminders_worker',
                $args
            );
        }
    }

    /**
     * Schedule all reminders.
     *
     * @return void
     */
    public function schedule_reminders()
    {
        $reminders = MeprCptModel::all('MeprReminder');

        if (!empty($reminders)) {
            foreach ($reminders as $r) {
                $vr = $this->get_valid_reminder($r->ID);

                if ($vr !== false) {
                    $this->schedule_reminder($r->ID);
                } else {
                    $this->unschedule_reminder($r->ID);
                }
            }
        }
    }

    /**
     * Unschedule a reminder for a specific ID.
     *
     * @param integer $id The ID of the reminder to unschedule.
     *
     * @return void
     */
    public function unschedule_reminder($id)
    {
        $args      = [$id];
        $timestamp = wp_next_scheduled('mepr_reminders_worker', $args);
        wp_unschedule_event($timestamp, 'mepr_reminders_worker', $args);
    }

    /**
     * Unschedule all reminders.
     *
     * @return void
     */
    public function unschedule_reminders()
    {
        $reminders = MeprCptModel::all('MeprReminder');

        if (!empty($reminders)) {
            foreach ($reminders as $r) {
                $this->unschedule_reminder($r->ID);
            }
        }
    }

    /**
     * Define custom columns for the reminders list table.
     *
     * @param array $columns The existing columns.
     *
     * @return array The modified columns.
     */
    public function columns($columns)
    {
        $columns = [
            'cb'                => '<input type="checkbox" />',
            'title'             => __('Reminder Title', 'memberpress'),
            'send_to_admin'     => __('Send Notice to Admin', 'memberpress'),
            'send_to_member'    => __('Send Reminder to Member', 'memberpress'),
            'reminder_products' => __('Memberships', 'memberpress'),
        ];

        return $columns;
    }

    /**
     * Display custom column content for the reminders list table.
     *
     * @param string  $column  The name of the column.
     * @param integer $post_id The ID of the post.
     *
     * @return void
     */
    public function custom_columns($column, $post_id)
    {
        $reminder = $this->get_valid_reminder($post_id);

        if ($reminder !== false) {
            switch ($reminder->trigger_event) {
                case 'sub-expires':
                    $uclass = 'MeprUserSubExpiresReminderEmail';
                    $aclass = 'MeprAdminSubExpiresReminderEmail';
                    break;
                case 'sub-renews':
                    $uclass = 'MeprUserSubRenewsReminderEmail';
                    $aclass = 'MeprAdminSubRenewsReminderEmail';
                    break;
                case 'signup-abandoned':
                    $uclass = 'MeprUserSignupAbandonedReminderEmail';
                    $aclass = 'MeprAdminSignupAbandonedReminderEmail';
                    break;
                case 'member-signup':
                    $uclass = 'MeprUserMemberSignupReminderEmail';
                    $aclass = 'MeprAdminMemberSignupReminderEmail';
                    break;
                case 'cc-expires':
                    $uclass = 'MeprUserCcExpiresReminderEmail';
                    $aclass = 'MeprAdminCcExpiresReminderEmail';
                    break;
                case 'sub-trial-ends':
                    $uclass = 'MeprUserSubTrialEndsReminderEmail';
                    $aclass = 'MeprAdminSubTrialEndsReminderEmail';
                    break;
                default:
                    echo '';
                    return;
            }

            if ('send_to_admin' === $column || 'send_to_member' === $column) {
                $email_class = 'send_to_admin' === $column ? $aclass : $uclass;
                $enabled = (int) $reminder->emails[$email_class]['enabled'] > 0;

                printf(
                    '<span style="color: %s; font-size: 120%%"><strong>%s</strong></span>',
                    $enabled ? esc_attr('limegreen') : esc_attr('red'),
                    $enabled ? '✔︎' : '✖︎'
                );
            } elseif ('reminder_products' === $column) {
                echo esc_html(implode(', ', $reminder->get_formatted_products()));
            }
        }
    }

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

    /**
     * Add meta boxes for the reminders post type.
     *
     * @return void
     */
    public function add_meta_boxes()
    {
        add_meta_box(
            'mp-reminder-trigger',
            __('Trigger', 'memberpress'),
            [$this, 'trigger_meta_box'],
            MeprReminder::$cpt,
            'normal'
        );
        add_meta_box(
            'mp-reminder-emails',
            __('Emails', 'memberpress'),
            [$this, 'emails_meta_box'],
            MeprReminder::$cpt,
            'normal'
        );
    }

    /**
     * Enqueue scripts and styles for the reminders admin page.
     *
     * @return void
     */
    public function enqueue_scripts()
    {
        global $current_screen;

        if ($current_screen->post_type === MeprReminder::$cpt) {
            wp_enqueue_style('mepr-jquery-ui-smoothness', MEPR_CSS_URL . '/vendor/jquery-ui/smoothness.min.css', [], '1.13.3');
            wp_dequeue_script('autosave'); // Disable auto-saving.
            wp_enqueue_style('mepr-emails-css', MEPR_CSS_URL . '/admin-emails.css', [], MEPR_VERSION);
            $email_locals = [
                'set_email_defaults_nonce' => wp_create_nonce('set_email_defaults'),
                'send_test_email_nonce'    => wp_create_nonce('send_test_email'),
            ];
            wp_enqueue_script('mepr-emails-js', MEPR_JS_URL . '/admin_emails.js', ['jquery'], MEPR_VERSION);
            wp_localize_script('mepr-emails-js', 'MeprEmail', $email_locals);
            wp_enqueue_style('mepr-reminders-css', MEPR_CSS_URL . '/admin-reminders.css', ['mepr-emails-css'], MEPR_VERSION);
            wp_enqueue_script('mepr-reminders-js', MEPR_JS_URL . '/admin_reminders.js', ['jquery','jquery-ui-spinner','mepr-emails-js'], MEPR_VERSION);
        }
    }

    /**
     * Render the trigger meta box.
     *
     * @return void
     */
    public function trigger_meta_box()
    {
        global $post_id;

        $reminder = new MeprReminder($post_id);
        $nonce    = wp_create_nonce(md5(MeprReminder::$nonce_str . wp_salt()));

        MeprView::render('/admin/reminders/trigger', get_defined_vars());
    }

    /**
     * Render the emails meta box.
     *
     * @return void
     */
    public function emails_meta_box()
    {
        global $post_id;

        $reminder = new MeprReminder($post_id);

        MeprView::render('/admin/reminders/emails', get_defined_vars());
    }

    /**
     * Save post data for reminders.
     *
     * @param integer $post_id The ID of the post being saved.
     *
     * @return integer|void The post ID or void if not applicable.
     */
    public function save_postdata($post_id)
    {
        $post = get_post($post_id);

        if (
            !wp_verify_nonce(
                (isset($_POST[MeprReminder::$nonce_str])) ? sanitize_text_field(wp_unslash($_POST[MeprReminder::$nonce_str])) : '',
                md5(MeprReminder::$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 === MeprReminder::$cpt) {
            $reminder = new MeprReminder($post_id);

            $reminder->trigger_length   = sanitize_text_field(wp_unslash($_POST[MeprReminder::$trigger_length_str] ?? ''));
            $reminder->trigger_interval = sanitize_text_field(wp_unslash($_POST[MeprReminder::$trigger_interval_str] ?? ''));
            $reminder->trigger_timing   = sanitize_text_field(wp_unslash($_POST[MeprReminder::$trigger_timing_str] ?? ''));
            $reminder->trigger_event    = sanitize_text_field(wp_unslash($_POST[MeprReminder::$trigger_event_str] ?? ''));
            $reminder->filter_products  = false;
            $reminder->products         = [];

            // Override filter by products vars.
            if (isset($_POST[MeprReminder::$filter_products_str]) && !empty($_POST[MeprReminder::$products_str])) {
                $reminder->filter_products = true;
                $reminder->products        = array_map('sanitize_text_field', wp_unslash($_POST[MeprReminder::$products_str]));
            }

            // Notification Settings.
            $emails = [];
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $reminder_emails = wp_unslash($_POST[MeprReminder::$emails_str] ?? []);
            foreach ($reminder_emails as $email => $vals) {
                $emails[sanitize_text_field($email)] = [
                    'enabled'      => isset($vals['enabled']),
                    'use_template' => isset($vals['use_template']),
                    'subject'      => sanitize_text_field($vals['subject']),
                    'body'         => MeprUtils::maybe_wpautop(wp_kses_post($vals['body'])),
                ];
            }

            $reminder->emails = $emails;

            // Don't quite need this yet
            // $reminder = $this->validate($reminder);.
            $reminder->store_meta(); // Only storing metadata here.

            MeprHooks::do_action('mepr_reminder_save_meta', $reminder);
        }
    }

    /**
     * CRON SPECIFIC METHODS
     **/

    /**
     * Define custom cron intervals.
     *
     * @param array $schedules The existing cron schedules.
     *
     * @return array The modified cron schedules.
     */
    public function intervals($schedules)
    {
        $schedules[ 'mepr_reminders_worker_interval' ] = [
            'interval' => MeprUtils::minutes(15),
            'display'  => 'MemberPress Reminders Worker',
        ];

        return $schedules;
    }

    /**
     * Get a valid reminder object by ID.
     *
     * @param integer $id The ID of the reminder.
     *
     * @return MeprReminder|false The reminder object or false if invalid.
     */
    public function get_valid_reminder($id)
    {
        // If the remider_id is empty then forget it.
        if (empty($id)) {
            return false;
        }

        $post = get_post($id);

        // Post not found? fail.
        if (empty($post)) {
            return false;
        }

        // Not the right post type? fail.
        if ($post->post_type !== MeprReminder::$cpt) {
            return false;
        }

        // Not a published post? fail.
        if ($post->post_status !== 'publish') {
            return false;
        }

        $reminder = new MeprReminder($id);

        // ID is empty? fail.
        if (empty($reminder->ID)) {
            return false;
        }

        return $reminder;
    }

    /**
     * Worker function to process reminders.
     *
     * @param integer $reminder_id The ID of the reminder to process.
     *
     * @return void
     */
    public function worker($reminder_id)
    {
        $reminder = $this->get_valid_reminder($reminder_id);

        if ($reminder !== false) {
            if (function_exists('set_time_limit')) {
                // Unlimited run time.
                set_time_limit(0); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
            }
            $run_limit = MeprUtils::minutes(10); // Limit to 10 minutes.

            // The event name will be the same no matter what we're doing here.
            $event = "{$reminder->trigger_timing}-{$reminder->trigger_event}-reminder";

            while ($this->run_time() < $run_limit) {
                $args = $reminder_id;
                $obj  = null;

                switch ($reminder->trigger_event) {
                    case 'sub-expires':
                        $txn = $reminder->get_next_expiring_txn();
                        if ($txn) {
                            $obj = new MeprTransaction($txn->id); // We need the actual model.
                        }
                        break;
                    case 'sub-renews':
                        if ($reminder->trigger_timing === 'before') {
                            $txn = $reminder->get_next_renewing_txn();
                            if ($txn) {
                                $obj = new MeprTransaction($txn->id); // We need the actual model.
                            }
                        } elseif ($reminder->trigger_timing === 'after') {
                            $txn = $reminder->get_renewed_txn();
                            if ($txn) {
                                $obj = new MeprTransaction($txn->id); // We need the actual model.
                            }
                        }
                        break;
                    case 'member-signup':
                        $txn_id = $reminder->get_next_member_signup();
                        if ($txn_id) {
                            $obj = new MeprTransaction($txn_id);
                        }
                        break;
                    case 'signup-abandoned':
                        $txn_id = $reminder->get_next_abandoned_signup();
                        if ($txn_id) {
                            $obj = new MeprTransaction($txn_id);
                        }
                        break;
                    case 'cc-expires':
                        $sub_id = $reminder->get_next_expired_cc();
                        if ($sub_id) {
                            $obj  = new MeprSubscription($sub_id);
                            $args = "{$reminder_id}|{$obj->cc_exp_month}|{$obj->cc_exp_year}";
                        }
                        break;
                    case 'sub-trial-ends':
                        if ($reminder->trigger_timing === 'before') {
                            $sub_id = $reminder->get_next_trial_ends_subs();
                            if ($sub_id) {
                                $obj  = new MeprSubscription($sub_id);
                                $args = "{$reminder_id}|{$obj->trial_days}";
                            }
                        }
                        break;
                    default:
                        $this->unschedule_reminder($reminder_id);
                        break;
                }

                if (isset($obj)) {
                    // We just catch the hooks from these events.
                    MeprEvent::record($event, $obj, $args);
                } else {
                    break; // Break out of the while loop.
                }
            }//end while
        }
    }

    /**
     * Get the runtime of the current process.
     *
     * @return integer The runtime in seconds.
     */
    private function run_time()
    {
        static $start_time;

        if (!isset($start_time)) {
            $start_time = time();
        }

        return ( time() - $start_time );
    }

    /**
     * Send reminder emails to users and admins.
     *
     * @param MeprUser $usr    The user object.
     * @param string   $uclass The user email class.
     * @param string   $aclass The admin email class.
     * @param array    $params The email parameters.
     * @param array    $args   The additional arguments.
     *
     * @return void
     */
    private function send_emails($usr, $uclass, $aclass, $params, $args)
    {
        try {
            $uemail     = MeprEmailFactory::fetch($uclass, 'MeprBaseReminderEmail', $args);
            $uemail->to = $usr->formatted_email();
            $uemail->send_if_enabled($params);

            $aemail = MeprEmailFactory::fetch($aclass, 'MeprBaseReminderEmail', $args);
            $aemail->send_if_enabled($params);
        } catch (Exception $e) {
            // Fail silently for now.
        }
    }

    /**
     * Send reminders based on the event.
     *
     * @param MeprEvent $event The event object.
     *
     * @return void
     */
    public function send_reminders($event)
    {
        // Now that we support renewals on one-time purchases -- we need to make sure they don't get reminded of expirations
        // if they have already renewed their one-time subscription again before the expiring sub reminder is sent out.
        $disable_email = false; // Do not send the emails if this gets set to true.

        if ($event->evt_id_type === 'transactions') {
            $txn = new MeprTransaction($event->evt_id);

            // Do not send reminders to sub-accounts.
            if (isset($txn->parent_transaction_id) && $txn->parent_transaction_id > 0) {
                $disable_email = true;
            }

            $usr      = $txn->user();
            $prd      = new MeprProduct($txn->product_id);
            $reminder = $this->get_valid_reminder($event->args);

            // Fail silently if reminder is invalid.
            if ($reminder === false) {
                return;
            }

            $params = array_merge(MeprRemindersHelper::get_email_params($reminder), MeprTransactionsHelper::get_email_params($txn));

            switch ($reminder->trigger_event) {
                case 'sub-expires':
                    // Don't send a reminder if the user has already renewed either a one-time or an offline subscription.
                    if ($reminder->trigger_timing === 'before') { // Handle when the reminder should go out before.
                        $txn_count = count($usr->transactions_for_product($txn->product_id, false, true));

                        // The txn_count > 1 works well for both renewals and offline subs actually because transactions_for_product
                        // should only ever return a count of currently active (payment type) transactions and no expired transactions.
                        if ($txn_count > 1) {
                            $disable_email = true;
                        }
                    } else { // Handle when the reminder should go out after
                        // Don't send to folks if they have an active txn on this subscription (OR one in the
                        // products group) already yo.
                        $active_subs = array_map('intval', $usr->active_product_subscriptions('ids'));
                        $product     = new MeprProduct($txn->product_id);
                        $grp         = new MeprGroup($product->group_id);

                        // If product is in a group with an upgrade path check for all memberships in that group.
                        if ($product->group_id && $product->group_id > 0 && $grp->is_upgrade_path) {
                            foreach ($grp->products('ids') as $prd_id) {
                                if (in_array((int) $prd_id, $active_subs, true)) {
                                    $disable_email = true;
                                    break; // Breack out of the loop once we find one.
                                }
                            }
                        } else {
                            // Just check this product.
                            if (in_array((int) $txn->product_id, $active_subs, true)) {
                                $disable_email = true;
                            }
                        }
                    }

                    $uclass = 'MeprUserSubExpiresReminderEmail';
                    $aclass = 'MeprAdminSubExpiresReminderEmail';
                    break;
                case 'sub-renews':
                    if ($reminder->trigger_timing === 'after') {
                        $sub       = $txn->subscription();
                        $txn_count = (isset($sub->txn_count) && $sub->txn_count) ? $sub->txn_count : 0;
                        if ($txn_count < 2 && ( (bool) $sub->trial === false || ($sub->trial && $sub->trial_amount > 0.00))) {
                                $disable_email = true;
                        }
                    }
                    $uclass = 'MeprUserSubRenewsReminderEmail';
                    $aclass = 'MeprAdminSubRenewsReminderEmail';
                    break;
                case 'signup-abandoned':
                    // Make sure the user is not active on another membership.
                    $active_subs = $usr->active_product_subscriptions('ids');

                    if (!empty($active_subs)) {
                        $disable_email = true;
                    }

                    $uclass = 'MeprUserSignupAbandonedReminderEmail';
                    $aclass = 'MeprAdminSignupAbandonedReminderEmail';
                    break;
                case 'member-signup':
                    $uclass = 'MeprUserMemberSignupReminderEmail';
                    $aclass = 'MeprAdminMemberSignupReminderEmail';
                    break;
                default:
                    $uclass = $aclass = '';
            }

            $args = [['reminder_id' => $event->args]];

            $disable_email = MeprHooks::apply_filters("mepr_{$reminder->trigger_event}_reminder_disable", $disable_email, $reminder, $usr, $prd, $event);
            if (!$disable_email) {
                $this->send_emails($usr, $uclass, $aclass, $params, $args);
            }
        } elseif ($event->evt_id_type === 'subscriptions') {
            $sub = new MeprSubscription($event->evt_id);

            $usr      = $sub->user();
            $prd      = new MeprProduct($sub->product_id);
            $reminder = $this->get_valid_reminder($event->args);

            // Fail silently if reminder is invalid.
            if ($reminder === false) {
                return;
            }

            $parents = get_user_meta($usr->ID, 'mpca_corporate_account_id');

            // Do not email sub account users.
            if (count($parents) > 0) {
                $disable_email = true;
            }

            $params = array_merge(
                MeprRemindersHelper::get_email_params($reminder),
                MeprSubscriptionsHelper::get_email_params($sub)
            );

            switch ($reminder->trigger_event) {
                case 'sub-trial-ends':
                    if ('before' === $reminder->trigger_timing) {
                        $uclass = 'MeprUserSubTrialEndsReminderEmail';
                        $aclass = 'MeprAdminSubTrialEndsReminderEmail';
                    }
                    break;
                case 'cc-expires':
                    $uclass = 'MeprUserCcExpiresReminderEmail';
                    $aclass = 'MeprAdminCcExpiresReminderEmail';
                    break;
                default:
                    $uclass = $aclass = '';
            }

            $args = [['reminder_id' => $reminder->ID]];

            $disable_email = MeprHooks::apply_filters("mepr_{$reminder->trigger_event}_reminder_disable", $disable_email, $reminder, $usr, $prd, $event);
            if (!$disable_email) {
                $this->send_emails($usr, $uclass, $aclass, $params, $args);
            }
        }
    }

    /**
     * Delete a reminder and unschedule its cron job.
     *
     * @param integer $id The ID of the reminder to delete.
     *
     * @return void
     */
    public function delete($id)
    {
        global $post_type;
        if ($post_type !== MeprReminder::$cpt) {
            return;
        }
        $this->unschedule_reminder($id);
    }
}