300, 'display' => __( 'Every Five Minutes', 'maple-carts' ), ]; return $schedules; } /** * Create database tables. */ public static function create_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $carts_table = $wpdb->prefix . MAPLE_CARTS_TABLE; $emails_table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // Abandoned carts table. $sql = "CREATE TABLE {$carts_table} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, session_id VARCHAR(60) NOT NULL, email VARCHAR(200) DEFAULT NULL, cart_contents LONGTEXT, cart_total DECIMAL(10,2) DEFAULT 0, items_count INT DEFAULT 0, currency VARCHAR(10) DEFAULT 'USD', customer_name VARCHAR(200) DEFAULT NULL, customer_phone VARCHAR(50) DEFAULT NULL, billing_data LONGTEXT, status ENUM('active','abandoned','recovered','lost','converted') DEFAULT 'active', abandonment_type ENUM('browsing','checkout','payment') DEFAULT NULL, recovery_url VARCHAR(255) DEFAULT NULL, coupon_code VARCHAR(50) DEFAULT NULL, recovery_source VARCHAR(50) DEFAULT NULL, unsubscribed TINYINT(1) DEFAULT 0, ip_address VARCHAR(45) DEFAULT NULL, user_agent TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, first_seen_at DATETIME DEFAULT NULL, last_tracked_at DATETIME DEFAULT NULL, abandoned_at DATETIME DEFAULT NULL, recovered_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY session_id (session_id), KEY email (email), KEY status (status), KEY created_at (created_at), KEY updated_at (updated_at), KEY last_tracked_at (last_tracked_at), KEY status_tracked (status, last_tracked_at) ) {$charset_collate};"; dbDelta( $sql ); // Email templates table. $sql = "CREATE TABLE {$emails_table} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(200) NOT NULL, subject VARCHAR(500) NOT NULL, body LONGTEXT NOT NULL, delay_value INT(11) DEFAULT 1, delay_unit ENUM('minutes','hours','days') DEFAULT 'hours', is_active TINYINT(1) DEFAULT 0, include_coupon TINYINT(1) DEFAULT 0, coupon_type ENUM('percent','fixed_cart') DEFAULT 'percent', coupon_amount DECIMAL(10,2) DEFAULT 10, coupon_expires_days INT(11) DEFAULT 7, sort_order INT(11) DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY is_active (is_active), KEY sort_order (sort_order) ) {$charset_collate};"; dbDelta( $sql ); // Email log table. $sql = "CREATE TABLE {$log_table} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, cart_id BIGINT(20) UNSIGNED NOT NULL, email_template_id BIGINT(20) UNSIGNED NOT NULL, scheduled_at DATETIME NOT NULL, sent_at DATETIME DEFAULT NULL, status ENUM('pending','sent','failed','cancelled') DEFAULT 'pending', coupon_code VARCHAR(50) DEFAULT NULL, error_message TEXT, PRIMARY KEY (id), KEY cart_id (cart_id), KEY email_template_id (email_template_id), KEY status (status), KEY scheduled_at (scheduled_at) ) {$charset_collate};"; dbDelta( $sql ); } /** * Set default options. */ public static function set_default_options() { $defaults = [ 'enabled' => 'yes', 'cart_cutoff_time' => 15, // Minutes before cart is considered abandoned. 'delete_after_days' => 90, // Days to keep abandoned cart data. 'email_mode' => 'builtin', // 'builtin' or 'mailjet'. 'mailjet_api_key' => '', 'mailjet_secret_key'=> '', 'from_name' => get_bloginfo( 'name' ), 'from_email' => get_option( 'admin_email' ), 'reply_to' => get_option( 'admin_email' ), 'notify_admin' => 'yes', 'admin_email' => get_option( 'admin_email' ), 'require_consent' => 'no', 'consent_text' => __( 'I agree to receive cart reminder emails if I don\'t complete my purchase.', 'maple-carts' ), 'show_delete_link' => 'no', 'exclude_roles' => [], 'gdpr_notice' => '', ]; $existing = get_option( 'maple_carts_settings', [] ); $merged = array_merge( $defaults, $existing ); update_option( 'maple_carts_settings', $merged ); } /** * Seed default email templates. */ public static function seed_default_emails() { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; $count = $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); if ( $count > 0 ) { return; } $templates = [ [ 'name' => __( 'First Reminder', 'maple-carts' ), 'subject' => __( '{{customer_name}}, you left something behind!', 'maple-carts' ), 'body' => self::get_default_email_body( 1 ), 'delay_value' => 1, 'delay_unit' => 'hours', 'is_active' => 0, 'include_coupon' => 0, 'sort_order' => 1, ], [ 'name' => __( 'Second Reminder', 'maple-carts' ), 'subject' => __( 'Your cart is waiting, {{customer_name}}', 'maple-carts' ), 'body' => self::get_default_email_body( 2 ), 'delay_value' => 24, 'delay_unit' => 'hours', 'is_active' => 0, 'include_coupon' => 0, 'sort_order' => 2, ], [ 'name' => __( 'Final Reminder with Discount', 'maple-carts' ), 'subject' => __( 'Last chance! {{coupon_amount}} off your cart', 'maple-carts' ), 'body' => self::get_default_email_body( 3 ), 'delay_value' => 3, 'delay_unit' => 'days', 'is_active' => 0, 'include_coupon' => 1, 'coupon_type' => 'percent', 'coupon_amount' => 10, 'sort_order' => 3, ], ]; foreach ( $templates as $template ) { $wpdb->insert( $table, $template ); } } /** * Get default email body. * * @param int $template_num Template number. * @return string */ private static function get_default_email_body( $template_num ) { $bodies = [ 1 => '

Hi {{customer_name}},

We noticed you left some items in your cart at {{site_name}}. No worries — your cart is saved and ready when you are!

{{cart_contents}}

Complete Your Order

If you have any questions, just reply to this email.

Thanks,
{{site_name}}

{{unsubscribe_link}}{{delete_data_link}}

', 2 => '

Hi {{customer_name}},

Just a friendly reminder that your cart at {{site_name}} is still waiting for you.

{{cart_contents}}

Cart Total: {{cart_total}}

Return to Your Cart

Best,
{{site_name}}

{{unsubscribe_link}}{{delete_data_link}}

', 3 => '

Hi {{customer_name}},

This is your last reminder about the items in your cart. To help you complete your purchase, we\'re offering you an exclusive discount!

{{cart_contents}}

Use code {{coupon_code}} for {{coupon_amount}} off!

Claim Your Discount

This offer expires in {{coupon_expires}} days.

Thanks,
{{site_name}}

{{unsubscribe_link}}{{delete_data_link}}

', ]; return $bodies[ $template_num ] ?? $bodies[1]; } /** * Get a cart by session ID. * * @param string $session_id Session ID. * @return object|null */ public static function get_cart_by_session( $session_id ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_TABLE; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE session_id = %s", $session_id ) ); } /** * Get a cart by email. * * @param string $email Email address. * @param array $statuses Statuses to filter by. * @return object|null */ public static function get_cart_by_email( $email, $statuses = [ 'active', 'abandoned' ] ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_TABLE; $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); $params = array_merge( [ $email ], $statuses ); return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE email = %s AND status IN ({$placeholders}) ORDER BY created_at DESC LIMIT 1", $params ) ); } /** * Insert or update a cart. * * @param array $data Cart data. * @return int|false Cart ID or false on failure. */ public static function save_cart( $data ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_TABLE; // Always update last_tracked_at when saving. $data['last_tracked_at'] = current_time( 'mysql' ); $existing = null; if ( ! empty( $data['session_id'] ) ) { $existing = self::get_cart_by_session( $data['session_id'] ); } if ( $existing ) { $wpdb->update( $table, $data, [ 'id' => $existing->id ] ); $cart_id = $existing->id; } else { // Set first_seen_at for new carts. $data['first_seen_at'] = current_time( 'mysql' ); $wpdb->insert( $table, $data ); $cart_id = $wpdb->insert_id; } // Fire webhook for integrations (Facebook CAPI, etc.). if ( $cart_id ) { $cart = self::get_cart_by_id( $cart_id ); if ( $cart ) { /** * Fires when a cart is created or updated. * Useful for Facebook CAPI, retargeting pixels, etc. * * @param object $cart Cart object. */ do_action( 'maple_carts_cart_updated', $cart ); } } return $cart_id; } /** * Update cart status. * * @param int $cart_id Cart ID. * @param string $status New status. * @param array $extra Extra data to update. */ public static function update_cart_status( $cart_id, $status, $extra = [] ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_TABLE; $data = array_merge( [ 'status' => $status ], $extra ); if ( 'abandoned' === $status ) { $data['abandoned_at'] = current_time( 'mysql' ); } elseif ( 'recovered' === $status ) { $data['recovered_at'] = current_time( 'mysql' ); } $wpdb->update( $table, $data, [ 'id' => $cart_id ] ); // Fire hooks for integrations (Mailjet, etc.). $cart = self::get_cart_by_id( $cart_id ); if ( $cart ) { if ( 'abandoned' === $status ) { /** * Fires when a cart is marked as abandoned. * * @param int $cart_id Cart ID. * @param object $cart Cart object. */ do_action( 'maple_carts_cart_abandoned', $cart_id, $cart ); } elseif ( 'recovered' === $status ) { /** * Fires when a cart is recovered. * * @param int $cart_id Cart ID. * @param object $cart Cart object. */ do_action( 'maple_carts_cart_recovered', $cart_id, $cart ); } elseif ( 'converted' === $status ) { /** * Fires when a cart is converted (order placed). * * @param int $cart_id Cart ID. * @param object $cart Cart object. */ do_action( 'maple_carts_cart_converted', $cart_id, $cart ); } } } /** * Get cart by ID. * * @param int $cart_id Cart ID. * @return object|null */ public static function get_cart_by_id( $cart_id ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_TABLE; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $cart_id ) ); } /** * Get carts by status. * * @param string $status Status. * @param int $limit Limit. * @param int $offset Offset. * @return array */ public static function get_carts_by_status( $status, $limit = 50, $offset = 0 ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_TABLE; return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY created_at DESC LIMIT %d OFFSET %d", $status, $limit, $offset ) ); } /** * Count carts by status. * * @param string $status Status. * @return int */ public static function count_carts_by_status( $status ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_TABLE; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", $status ) ); } /** * Get all email templates. * * @param bool $active_only Only active templates. * @return array */ public static function get_email_templates( $active_only = false ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; $where = $active_only ? 'WHERE is_active = 1' : ''; return $wpdb->get_results( "SELECT * FROM {$table} {$where} ORDER BY sort_order ASC" ); } /** * Get email template by ID. * * @param int $id Template ID. * @return object|null */ public static function get_email_template( $id ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); } /** * Save email template. * * @param array $data Template data. * @return int|false Template ID or false. */ public static function save_email_template( $data ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; if ( ! empty( $data['id'] ) ) { $id = $data['id']; unset( $data['id'] ); $wpdb->update( $table, $data, [ 'id' => $id ] ); return $id; } else { $wpdb->insert( $table, $data ); return $wpdb->insert_id; } } /** * Delete email template. * * @param int $id Template ID. * @return bool */ public static function delete_email_template( $id ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; return $wpdb->delete( $table, [ 'id' => $id ] ) !== false; } /** * Schedule emails for a cart. * * @param int $cart_id Cart ID. */ public static function schedule_emails( $cart_id ) { global $wpdb; $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; $cart = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}" . MAPLE_CARTS_TABLE . " WHERE id = %d", $cart_id ) ); if ( ! $cart || $cart->unsubscribed ) { return; } $templates = self::get_email_templates( true ); if ( empty( $templates ) ) { return; } $base_time = $cart->abandoned_at ? $cart->abandoned_at : current_time( 'mysql' ); // Get all existing scheduled emails for this cart in one query. $existing_template_ids = $wpdb->get_col( $wpdb->prepare( "SELECT email_template_id FROM {$log_table} WHERE cart_id = %d", $cart_id ) ); $existing_set = array_flip( $existing_template_ids ); foreach ( $templates as $template ) { // Skip if already scheduled. if ( isset( $existing_set[ $template->id ] ) ) { continue; } // Calculate scheduled time. $delay_seconds = self::get_delay_seconds( $template->delay_value, $template->delay_unit ); $scheduled_at = gmdate( 'Y-m-d H:i:s', strtotime( $base_time ) + $delay_seconds ); $wpdb->insert( $log_table, [ 'cart_id' => $cart_id, 'email_template_id' => $template->id, 'scheduled_at' => $scheduled_at, 'status' => 'pending', ] ); } } /** * Get delay in seconds. * * @param int $value Delay value. * @param string $unit Delay unit. * @return int */ private static function get_delay_seconds( $value, $unit ) { switch ( $unit ) { case 'minutes': return $value * 60; case 'hours': return $value * 3600; case 'days': return $value * 86400; default: return $value * 3600; } } /** * Get pending emails to send. * * @return array */ public static function get_pending_emails() { global $wpdb; $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; $carts_table = $wpdb->prefix . MAPLE_CARTS_TABLE; $email_table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; $now = current_time( 'mysql' ); return $wpdb->get_results( $wpdb->prepare( "SELECT l.*, c.email, c.customer_name, c.cart_contents, c.cart_total, c.currency, c.session_id, c.coupon_code as cart_coupon, c.billing_data, e.subject, e.body, e.include_coupon, e.coupon_type, e.coupon_amount, e.coupon_expires_days FROM {$log_table} l JOIN {$carts_table} c ON l.cart_id = c.id JOIN {$email_table} e ON l.email_template_id = e.id WHERE l.status = 'pending' AND l.scheduled_at <= %s AND c.status = 'abandoned' AND c.unsubscribed = 0 ORDER BY l.scheduled_at ASC LIMIT 50", $now ) ); } /** * Update email log status. * * @param int $id Log ID. * @param string $status Status. * @param array $extra Extra data. */ public static function update_email_log( $id, $status, $extra = [] ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; $data = array_merge( [ 'status' => $status ], $extra ); if ( 'sent' === $status ) { $data['sent_at'] = current_time( 'mysql' ); } $wpdb->update( $table, $data, [ 'id' => $id ] ); } /** * Cancel pending emails for a cart. * * @param int $cart_id Cart ID. */ public static function cancel_pending_emails( $cart_id ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; $wpdb->update( $table, [ 'status' => 'cancelled' ], [ 'cart_id' => $cart_id, 'status' => 'pending' ] ); } /** * Get cart statistics. * * @param string $period Period (7days, 30days, all). * @return array */ public static function get_stats( $period = '30days' ) { global $wpdb; $table = $wpdb->prefix . MAPLE_CARTS_TABLE; $date_filter = ''; if ( '7days' === $period ) { $date_filter = "AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"; } elseif ( '30days' === $period ) { $date_filter = "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"; } $stats = [ 'abandoned' => 0, 'recovered' => 0, 'lost' => 0, 'abandoned_value' => 0, 'recovered_value' => 0, 'recovery_rate' => 0, ]; $results = $wpdb->get_results( "SELECT status, COUNT(*) as count, SUM(cart_total) as total FROM {$table} WHERE 1=1 {$date_filter} GROUP BY status" ); foreach ( $results as $row ) { if ( 'abandoned' === $row->status ) { $stats['abandoned'] = (int) $row->count; $stats['abandoned_value'] = (float) $row->total; } elseif ( 'recovered' === $row->status ) { $stats['recovered'] = (int) $row->count; $stats['recovered_value'] = (float) $row->total; } elseif ( 'lost' === $row->status ) { $stats['lost'] = (int) $row->count; } } $total_actionable = $stats['abandoned'] + $stats['recovered'] + $stats['lost']; if ( $total_actionable > 0 ) { $stats['recovery_rate'] = round( ( $stats['recovered'] / $total_actionable ) * 100, 1 ); } return $stats; } /** * Cleanup old data. * Runs daily, processes in batches to prevent memory issues. */ public static function cleanup_old_data() { global $wpdb; // Prevent overlapping runs. if ( get_transient( 'maple_carts_cleanup_running' ) ) { return; } set_transient( 'maple_carts_cleanup_running', 1, 3600 ); // 1 hour lock. $days = (int) Maple_Carts::get_option( 'delete_after_days', 90 ); $carts_table = $wpdb->prefix . MAPLE_CARTS_TABLE; $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; $batch_size = 500; // Delete old carts in batches. $wpdb->query( $wpdb->prepare( "DELETE FROM {$carts_table} WHERE created_at < DATE_SUB(NOW(), INTERVAL %d DAY) LIMIT %d", $days, $batch_size ) ); // Delete orphaned email logs in batches. $wpdb->query( "DELETE l FROM {$log_table} l LEFT JOIN {$carts_table} c ON l.cart_id = c.id WHERE c.id IS NULL LIMIT {$batch_size}" ); // Delete expired coupons created by this plugin (batched). $expired_coupons = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'shop_coupon' AND ID IN ( SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_maple_carts_coupon' AND meta_value = '1' ) AND ID IN ( SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'date_expires' AND meta_value < UNIX_TIMESTAMP() ) LIMIT %d", $batch_size ) ); foreach ( $expired_coupons as $coupon_id ) { wp_delete_post( $coupon_id, true ); } // Delete unused coupons older than 30 days (never used, cart may have converted another way). $unused_old_coupons = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm_maple ON p.ID = pm_maple.post_id AND pm_maple.meta_key = '_maple_carts_coupon' AND pm_maple.meta_value = '1' INNER JOIN {$wpdb->postmeta} pm_created ON p.ID = pm_created.post_id AND pm_created.meta_key = '_maple_carts_created_at' AND pm_created.meta_value < %d INNER JOIN {$wpdb->postmeta} pm_usage ON p.ID = pm_usage.post_id AND pm_usage.meta_key = 'usage_count' AND pm_usage.meta_value = '0' WHERE p.post_type = 'shop_coupon' LIMIT %d", strtotime( '-30 days' ), $batch_size ) ); foreach ( $unused_old_coupons as $coupon_id ) { wp_delete_post( $coupon_id, true ); } delete_transient( 'maple_carts_cleanup_running' ); } /** * Delete a cart. * * @param int $cart_id Cart ID. * @return bool */ public static function delete_cart( $cart_id ) { global $wpdb; // Delete email logs first. $wpdb->delete( $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE, [ 'cart_id' => $cart_id ] ); // Delete cart. return $wpdb->delete( $wpdb->prefix . MAPLE_CARTS_TABLE, [ 'id' => $cart_id ] ) !== false; } } // Register cron interval early. add_filter( 'cron_schedules', [ 'Maple_Carts_DB', 'add_cron_interval' ] );