monorepo/native/wordpress/maple-carts-wp/includes/class-maple-carts-tracking.php

767 lines
25 KiB
PHP

<?php
/**
* Cart tracking for Maple Carts.
*
* @package MapleCarts
*/
defined( 'ABSPATH' ) || exit;
/**
* Tracking class.
*/
class Maple_Carts_Tracking {
/**
* Single instance.
*
* @var Maple_Carts_Tracking
*/
private static $instance = null;
/**
* Get instance.
*
* @return Maple_Carts_Tracking
*/
public static function instance() {
if ( is_null( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
if ( 'yes' !== Maple_Carts::get_option( 'enabled', 'yes' ) ) {
return;
}
// Frontend tracking.
add_action( 'woocommerce_after_checkout_form', [ $this, 'tracking_script' ] );
add_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_after', [ $this, 'tracking_script' ] );
// AJAX handlers.
add_action( 'wp_ajax_maple_carts_save', [ $this, 'ajax_save_cart' ] );
add_action( 'wp_ajax_nopriv_maple_carts_save', [ $this, 'ajax_save_cart' ] );
// Cart recovery.
add_action( 'wp', [ $this, 'handle_recovery' ] );
add_action( 'wp', [ $this, 'handle_unsubscribe' ] );
// WooCommerce hooks.
add_action( 'woocommerce_new_order', [ $this, 'handle_new_order' ] );
add_action( 'woocommerce_thankyou', [ $this, 'handle_order_complete' ] );
add_action( 'woocommerce_order_status_changed', [ $this, 'handle_order_status_change' ], 10, 3 );
// Mark carts as abandoned via cron.
add_action( 'maple_carts_send_emails', [ $this, 'mark_abandoned_carts' ], 5 );
// Cleanup cron.
add_action( 'maple_carts_cleanup', [ 'Maple_Carts_DB', 'cleanup_old_data' ] );
}
/**
* Enqueue tracking script on checkout.
*/
public function tracking_script() {
if ( $this->should_skip_tracking() ) {
return;
}
wp_enqueue_script(
'maple-carts-tracking',
MAPLE_CARTS_URL . 'assets/js/tracking.js',
[ 'jquery' ],
MAPLE_CARTS_VERSION,
true
);
$session_id = $this->get_or_create_session_id();
wp_localize_script(
'maple-carts-tracking',
'mapleCartsData',
[
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'maple_carts_nonce' ),
'sessionId' => $session_id,
'gdprNotice' => Maple_Carts::get_option( 'gdpr_notice', '' ),
]
);
}
/**
* Check if tracking should be skipped.
*
* @return bool
*/
private function should_skip_tracking() {
// Skip if disabled via cookie.
if ( isset( $_COOKIE['maple_carts_skip'] ) ) {
return true;
}
// Skip for excluded roles.
if ( is_user_logged_in() ) {
$user = wp_get_current_user();
$exclude_roles = Maple_Carts::get_option( 'exclude_roles', [] );
if ( ! empty( $exclude_roles ) && array_intersect( $user->roles, $exclude_roles ) ) {
return true;
}
}
return false;
}
/**
* Get or create session ID.
*
* @return string
*/
private function get_or_create_session_id() {
if ( WC()->session ) {
$session_id = WC()->session->get( 'maple_carts_session_id' );
if ( ! $session_id ) {
$session_id = wp_generate_uuid4();
WC()->session->set( 'maple_carts_session_id', $session_id );
}
return $session_id;
}
// Fallback to cookie when WC session is unavailable.
if ( isset( $_COOKIE['maple_carts_session'] ) ) {
return sanitize_text_field( wp_unslash( $_COOKIE['maple_carts_session'] ) );
}
// Generate new session ID and persist to cookie.
$session_id = wp_generate_uuid4();
$this->set_session_cookie( $session_id );
return $session_id;
}
/**
* Set session cookie for fallback persistence.
*
* @param string $session_id Session ID.
*/
private function set_session_cookie( $session_id ) {
if ( headers_sent() ) {
return;
}
$secure = is_ssl();
$httponly = true;
$samesite = 'Lax';
$expire = time() + YEAR_IN_SECONDS;
if ( PHP_VERSION_ID >= 70300 ) {
setcookie( 'maple_carts_session', $session_id, [
'expires' => $expire,
'path' => '/',
'domain' => '',
'secure' => $secure,
'httponly' => $httponly,
'samesite' => $samesite,
] );
} else {
setcookie( 'maple_carts_session', $session_id, $expire, '/; SameSite=' . $samesite, '', $secure, $httponly );
}
// Also set in $_COOKIE for immediate availability.
$_COOKIE['maple_carts_session'] = $session_id;
}
/**
* AJAX handler for saving cart data.
* Includes rate limiting for security.
*/
public function ajax_save_cart() {
check_ajax_referer( 'maple_carts_nonce', 'nonce' );
// Rate limiting: max 10 requests per minute per IP.
$ip = $this->get_client_ip();
$rate_key = 'maple_carts_rate_' . md5( $ip );
$requests = (int) get_transient( $rate_key );
if ( $requests >= 10 ) {
wp_send_json_error( 'Rate limit exceeded. Please try again later.' );
}
set_transient( $rate_key, $requests + 1, MINUTE_IN_SECONDS );
// Check for consent if required.
if ( ! Maple_Carts_Privacy::has_consent() ) {
wp_send_json_error( 'Consent not given' );
}
// Record consent.
Maple_Carts_Privacy::record_consent();
$session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
if ( empty( $session_id ) ) {
wp_send_json_error( 'Missing session ID' );
}
// Get cart data from WooCommerce.
$cart = WC()->cart;
if ( ! $cart || $cart->is_empty() ) {
wp_send_json_error( 'Cart is empty' );
}
$cart_contents = [];
foreach ( $cart->get_cart() as $key => $item ) {
$product = $item['data'];
$cart_contents[] = [
'product_id' => $item['product_id'],
'variation_id' => $item['variation_id'] ?? 0,
'quantity' => $item['quantity'],
'name' => $product->get_name(),
'price' => $product->get_price(),
'image' => wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' ),
'permalink' => $product->get_permalink(),
];
}
// Collect billing data.
$billing_data = [];
$billing_fields = [
'first_name', 'last_name', 'phone', 'address_1', 'address_2',
'city', 'state', 'postcode', 'country'
];
foreach ( $billing_fields as $field ) {
$key = 'billing_' . $field;
if ( isset( $_POST[ $key ] ) ) {
$billing_data[ $field ] = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
}
}
$first_name = $billing_data['first_name'] ?? '';
$last_name = $billing_data['last_name'] ?? '';
$customer_name = trim( "$first_name $last_name" );
// Calculate items count.
$items_count = 0;
foreach ( $cart_contents as $item ) {
$items_count += $item['quantity'];
}
// Prepare cart data.
$data = [
'session_id' => $session_id,
'email' => $email,
'cart_contents' => maybe_serialize( $cart_contents ),
'cart_total' => $cart->get_cart_contents_total(),
'items_count' => $items_count,
'currency' => get_woocommerce_currency(),
'customer_name' => $customer_name ?: null,
'customer_phone' => $billing_data['phone'] ?? null,
'billing_data' => maybe_serialize( $billing_data ),
'ip_address' => $this->get_client_ip(),
'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null,
];
// Generate recovery URL.
$data['recovery_url'] = $this->generate_recovery_url( $session_id );
$cart_id = Maple_Carts_DB::save_cart( $data );
if ( $cart_id ) {
wp_send_json_success( [ 'cart_id' => $cart_id ] );
} else {
wp_send_json_error( 'Failed to save cart' );
}
}
/**
* Get client IP address.
*
* @return string
*/
private function get_client_ip() {
$ip_keys = [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ];
foreach ( $ip_keys as $key ) {
if ( ! empty( $_SERVER[ $key ] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
// Handle comma-separated IPs.
if ( strpos( $ip, ',' ) !== false ) {
$ip = trim( explode( ',', $ip )[0] );
}
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return $ip;
}
}
}
return '0.0.0.0';
}
/**
* Generate recovery URL with HMAC signature.
*
* @param string $session_id Session ID.
* @param string $coupon Optional coupon code.
* @return string
*/
public function generate_recovery_url( $session_id, $coupon = '' ) {
$data = [
'sid' => $session_id,
'c' => $coupon,
't' => time(),
];
// Sign the token with HMAC.
$payload = wp_json_encode( $data );
$signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() );
$token = base64_encode( $payload . '|' . $signature );
return add_query_arg( 'maple_recover', $token, wc_get_checkout_url() );
}
/**
* Generate unsubscribe URL with HMAC signature.
*
* @param string $session_id Session ID.
* @return string
*/
public function generate_unsubscribe_url( $session_id ) {
$data = [
'sid' => $session_id,
't' => time(),
];
$payload = wp_json_encode( $data );
$signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() );
$token = base64_encode( $payload . '|' . $signature );
return add_query_arg( 'maple_unsubscribe', $token, home_url() );
}
/**
* Verify and decode a signed token.
*
* @param string $token The token to verify.
* @param int $max_age Maximum age in seconds (0 = no expiry).
* @return array|false Decoded data or false if invalid.
*/
private function verify_token( $token, $max_age = 0 ) {
$decoded = base64_decode( $token, true );
if ( ! $decoded || strpos( $decoded, '|' ) === false ) {
return false;
}
$parts = explode( '|', $decoded, 2 );
if ( count( $parts ) !== 2 ) {
return false;
}
list( $payload, $signature ) = $parts;
// Verify HMAC signature (timing-safe comparison).
$expected_signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() );
if ( ! hash_equals( $expected_signature, $signature ) ) {
return false;
}
$data = json_decode( $payload, true );
if ( ! $data ) {
return false;
}
// Check token age if max_age is set.
if ( $max_age > 0 && isset( $data['t'] ) ) {
if ( ( time() - $data['t'] ) > $max_age ) {
return false;
}
}
return $data;
}
/**
* Get secret key for token signing.
*
* @return string
*/
private function get_token_secret() {
// Use WordPress auth key + salt for signing.
return wp_salt( 'auth' ) . 'maple_carts';
}
/**
* Handle cart recovery.
*/
public function handle_recovery() {
if ( ! isset( $_GET['maple_recover'] ) ) {
return;
}
$token = sanitize_text_field( wp_unslash( $_GET['maple_recover'] ) );
// Verify signed token (valid for 30 days).
$data = $this->verify_token( $token, 30 * DAY_IN_SECONDS );
if ( ! $data || empty( $data['sid'] ) ) {
wc_add_notice( __( 'This recovery link has expired or is invalid.', 'maple-carts' ), 'error' );
wp_safe_redirect( wc_get_page_permalink( 'shop' ) );
exit;
}
$session_id = sanitize_text_field( $data['sid'] );
$cart = Maple_Carts_DB::get_cart_by_session( $session_id );
if ( ! $cart || ! in_array( $cart->status, [ 'abandoned', 'active' ], true ) ) {
return;
}
// Restore cart contents.
$cart_contents = maybe_unserialize( $cart->cart_contents );
if ( ! empty( $cart_contents ) && is_array( $cart_contents ) ) {
WC()->cart->empty_cart();
foreach ( $cart_contents as $item ) {
$product_id = $item['product_id'];
$quantity = $item['quantity'];
$variation_id = $item['variation_id'] ?? 0;
// Check if product exists and is in stock.
$product = wc_get_product( $variation_id ? $variation_id : $product_id );
if ( $product && $product->is_in_stock() ) {
WC()->cart->add_to_cart( $product_id, $quantity, $variation_id );
}
}
}
// Pre-fill billing data.
$billing_data = maybe_unserialize( $cart->billing_data );
if ( ! empty( $billing_data ) ) {
WC()->session->set( 'maple_carts_billing', $billing_data );
add_filter( 'woocommerce_checkout_get_value', [ $this, 'prefill_checkout_fields' ], 10, 2 );
}
// Apply coupon if provided.
$coupon = ! empty( $data['c'] ) ? sanitize_text_field( $data['c'] ) : '';
if ( $coupon && ! WC()->cart->has_discount( $coupon ) ) {
WC()->cart->apply_coupon( $coupon );
}
// Store session ID for tracking.
WC()->session->set( 'maple_carts_session_id', $session_id );
WC()->session->set( 'maple_carts_recovering', true );
// Redirect to clean URL.
wp_safe_redirect( wc_get_checkout_url() );
exit;
}
/**
* Pre-fill checkout fields.
*
* @param mixed $value Default value.
* @param string $input Field name.
* @return mixed
*/
public function prefill_checkout_fields( $value, $input ) {
$billing_data = WC()->session->get( 'maple_carts_billing' );
if ( ! $billing_data ) {
return $value;
}
$field = str_replace( 'billing_', '', $input );
if ( isset( $billing_data[ $field ] ) ) {
return $billing_data[ $field ];
}
return $value;
}
/**
* Handle unsubscribe.
*/
public function handle_unsubscribe() {
if ( ! isset( $_GET['maple_unsubscribe'] ) ) {
return;
}
$token = sanitize_text_field( wp_unslash( $_GET['maple_unsubscribe'] ) );
// Verify signed token (valid for 90 days).
$data = $this->verify_token( $token, 90 * DAY_IN_SECONDS );
if ( ! $data || empty( $data['sid'] ) ) {
wc_add_notice( __( 'This unsubscribe link has expired or is invalid.', 'maple-carts' ), 'error' );
wp_safe_redirect( wc_get_page_permalink( 'shop' ) );
exit;
}
$session_id = sanitize_text_field( $data['sid'] );
$cart = Maple_Carts_DB::get_cart_by_session( $session_id );
if ( $cart ) {
global $wpdb;
$wpdb->update(
$wpdb->prefix . MAPLE_CARTS_TABLE,
[ 'unsubscribed' => 1 ],
[ 'id' => $cart->id ]
);
// Cancel pending emails.
Maple_Carts_DB::cancel_pending_emails( $cart->id );
// Set cookie to skip future tracking (with security flags).
$secure = is_ssl();
$httponly = true;
$samesite = 'Lax';
if ( PHP_VERSION_ID >= 70300 ) {
setcookie( 'maple_carts_skip', '1', [
'expires' => time() + YEAR_IN_SECONDS,
'path' => '/',
'secure' => $secure,
'httponly' => $httponly,
'samesite' => $samesite,
] );
} else {
setcookie( 'maple_carts_skip', '1', time() + YEAR_IN_SECONDS, '/; SameSite=' . $samesite, '', $secure, $httponly );
}
wc_add_notice( __( 'You have been unsubscribed from cart reminder emails.', 'maple-carts' ), 'success' );
}
wp_safe_redirect( wc_get_page_permalink( 'shop' ) );
exit;
}
/**
* Handle new order creation.
*
* @param int $order_id Order ID.
*/
public function handle_new_order( $order_id ) {
$this->process_order_completion( $order_id );
}
/**
* Handle order complete (thank you page).
*
* @param int $order_id Order ID.
*/
public function handle_order_complete( $order_id ) {
$this->process_order_completion( $order_id );
}
/**
* Process order completion.
*
* @param int $order_id Order ID.
*/
private function process_order_completion( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$email = $order->get_billing_email();
if ( ! $email ) {
return;
}
// Check session first.
$session_id = null;
$is_recovery = false;
if ( WC()->session ) {
$session_id = WC()->session->get( 'maple_carts_session_id' );
$is_recovery = WC()->session->get( 'maple_carts_recovering' );
}
$cart = null;
if ( $session_id ) {
$cart = Maple_Carts_DB::get_cart_by_session( $session_id );
}
if ( ! $cart ) {
$cart = Maple_Carts_DB::get_cart_by_email( $email );
}
if ( ! $cart ) {
return;
}
// Determine new status and recovery source.
$new_status = 'converted';
$recovery_source = null;
if ( in_array( $cart->status, [ 'abandoned' ], true ) ) {
$new_status = 'recovered';
// Determine recovery source from UTM parameters.
if ( $is_recovery ) {
// Came via recovery link - check UTM source for attribution.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$utm_source = isset( $_GET['utm_source'] ) ? sanitize_text_field( wp_unslash( $_GET['utm_source'] ) ) : '';
switch ( $utm_source ) {
case 'mailjet':
$recovery_source = 'mailjet';
break;
case 'maple_carts':
$recovery_source = 'builtin_email';
break;
default:
// Generic recovery link (e.g., shared, bookmarked).
$recovery_source = 'email_link';
break;
}
} else {
// Returned directly without clicking recovery link.
$recovery_source = 'direct';
}
// Notify admin of recovery.
if ( 'yes' === Maple_Carts::get_option( 'notify_admin', 'yes' ) ) {
$this->send_admin_recovery_notification( $order, $cart );
}
}
Maple_Carts_DB::update_cart_status( $cart->id, $new_status, [ 'recovery_source' => $recovery_source ] );
Maple_Carts_DB::cancel_pending_emails( $cart->id );
// Clear session data.
if ( WC()->session ) {
WC()->session->__unset( 'maple_carts_session_id' );
WC()->session->__unset( 'maple_carts_recovering' );
WC()->session->__unset( 'maple_carts_billing' );
}
}
/**
* Handle order status change.
*
* @param int $order_id Order ID.
* @param string $old_status Old status.
* @param string $new_status New status.
*/
public function handle_order_status_change( $order_id, $old_status, $new_status ) {
// Handle completed/processing orders.
$completed_statuses = [ 'completed', 'processing' ];
if ( in_array( $new_status, $completed_statuses, true ) ) {
$this->process_order_completion( $order_id );
}
}
/**
* Mark carts as abandoned.
* Processes in batches to prevent memory issues.
*/
public function mark_abandoned_carts() {
global $wpdb;
// Prevent overlapping cron runs.
if ( get_transient( 'maple_carts_marking_abandoned' ) ) {
return;
}
set_transient( 'maple_carts_marking_abandoned', 1, 300 ); // 5 minute lock.
$table = $wpdb->prefix . MAPLE_CARTS_TABLE;
$cutoff_time = (int) Maple_Carts::get_option( 'cart_cutoff_time', 15 );
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$cutoff_time} minutes" ) );
$batch_size = 100; // Process 100 at a time.
// Get active carts that should be marked as abandoned (limited batch).
// Use COALESCE to handle existing carts that don't have last_tracked_at set yet.
$carts = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, email, billing_data FROM {$table}
WHERE status = 'active'
AND email IS NOT NULL
AND email != ''
AND COALESCE(last_tracked_at, updated_at) < %s
LIMIT %d",
$cutoff,
$batch_size
)
);
foreach ( $carts as $cart ) {
// Determine abandonment type.
$abandonment_type = $this->detect_abandonment_type( $cart );
Maple_Carts_DB::update_cart_status( $cart->id, 'abandoned', [
'abandonment_type' => $abandonment_type,
] );
Maple_Carts_DB::schedule_emails( $cart->id );
}
delete_transient( 'maple_carts_marking_abandoned' );
}
/**
* Detect abandonment type based on cart data.
*
* @param object $cart Cart object (minimal data from query).
* @return string Abandonment type: browsing, checkout, or payment.
*/
private function detect_abandonment_type( $cart ) {
// No email = browsing abandonment (but we don't track these, so this shouldn't happen).
if ( empty( $cart->email ) ) {
return 'browsing';
}
// Check billing data for payment indicators.
$billing_data = maybe_unserialize( $cart->billing_data );
// If we have address data, they got further into checkout.
$has_address = ! empty( $billing_data['address_1'] ) ||
! empty( $billing_data['city'] ) ||
! empty( $billing_data['postcode'] );
if ( $has_address ) {
// They filled out billing = likely payment abandonment.
return 'payment';
}
// Just email = checkout abandonment.
return 'checkout';
}
/**
* Send admin recovery notification.
*
* @param WC_Order $order Order object.
* @param object $cart Cart object.
*/
private function send_admin_recovery_notification( $order, $cart ) {
$admin_email = Maple_Carts::get_option( 'admin_email', get_option( 'admin_email' ) );
$site_name = get_bloginfo( 'name' );
$subject = sprintf(
/* translators: %1$s: Site name, %2$d: Order ID */
__( '[%1$s] Recovered Order #%2$d', 'maple-carts' ),
$site_name,
$order->get_id()
);
$message = sprintf(
/* translators: %1$d: Order ID, %2$s: Cart total, %3$s: Order link */
__( "Great news! An abandoned cart has been recovered.\n\nOrder #%1\$d\nRecovered Value: %2\$s\n\nView order: %3\$s", 'maple-carts' ),
$order->get_id(),
wc_price( $order->get_total() ),
admin_url( 'post.php?post=' . $order->get_id() . '&action=edit' )
);
wp_mail( $admin_email, $subject, $message );
}
}