Created
June 12, 2026 07:59
-
-
Save xlplugins/772fc6085be6d4bda04ba1e2dd5b7ea7 to your computer and use it in GitHub Desktop.
FB_coupon_diagnostics.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| class FB_Coupon_Diagnostics { | |
| /** Logger source / log-file slug. */ | |
| private static $source = 'fb-coupon-diagnostics'; | |
| /** Per-request correlation id — unique to one HTTP request. */ | |
| private static $rid = null; | |
| /** | |
| * Customer fingerprint (cid) — stable across every request in a session. | |
| * | |
| * Built from four signals so it survives shared NAT, CDN-masked IPs, and | |
| * mid-session network changes, while never writing raw PII to the log: | |
| * 1. WC session customer ID — anchors the whole funnel session | |
| * 2. WC / WP user ID — joins guest + logged-in legs of the journey | |
| * 3. SHA-1 of client IP — hashed, not stored raw (GDPR-safe) | |
| * 4. SHA-1 of User-Agent — differentiates devices behind the same NAT | |
| * | |
| * Two customers on the same IP still get different cids (session + UA differ). | |
| * Filter logs by cid to pull one customer's full journey across all request ids. | |
| */ | |
| private static $cid = null; | |
| /** Cached WC_Logger instance — resolved once, reused for every log call. */ | |
| private static $logger = null; | |
| /** Last-known applied coupon set, used to detect silent disappearance. */ | |
| private static $last_coupons = null; | |
| /* --------------------------------------------------------------------- | |
| * Config (filter-driven; no global constants) | |
| * ------------------------------------------------------------------- */ | |
| private static function enabled() { | |
| return (bool) apply_filters( 'fb_diag_enabled', true ); | |
| } | |
| private static function traces_enabled() { | |
| return (bool) apply_filters( 'fb_diag_traces', true ); | |
| } | |
| /** High-frequency before/after-totals snapshots. Mute if logs get noisy. */ | |
| private static function log_totals() { | |
| return (bool) apply_filters( 'fb_diag_log_totals', true ); | |
| } | |
| /* --------------------------------------------------------------------- | |
| * Bootstrap | |
| * ------------------------------------------------------------------- */ | |
| public static function init() { | |
| if ( ! self::enabled() || ! function_exists( 'WC' ) ) { | |
| return; | |
| } | |
| // --- Session / cart lifecycle ----------------------------------- | |
| add_action( 'woocommerce_cart_loaded_from_session', array( __CLASS__, 'on_cart_loaded' ), 9999 ); | |
| add_action( 'woocommerce_cart_emptied', array( __CLASS__, 'on_cart_emptied' ), 9999 ); | |
| add_action( 'woocommerce_cart_updated', array( __CLASS__, 'on_cart_updated' ), 9999 ); | |
| // --- Totals (before/after) -------------------------------------- | |
| add_action( 'woocommerce_before_calculate_totals', array( __CLASS__, 'on_before_totals' ), 9999 ); | |
| add_action( 'woocommerce_after_calculate_totals', array( __CLASS__, 'on_after_totals' ), 9999 ); | |
| // --- Coupons ---------------------------------------------------- | |
| add_action( 'woocommerce_applied_coupon', array( __CLASS__, 'on_coupon_applied' ), 9999, 1 ); | |
| add_action( 'woocommerce_removed_coupon', array( __CLASS__, 'on_coupon_removed' ), 9999, 1 ); | |
| // --- Checkout AJAX recalculation + order creation --------------- | |
| add_action( 'woocommerce_checkout_update_order_review', array( __CLASS__, 'on_update_order_review' ), 9999, 1 ); | |
| add_action( 'woocommerce_checkout_create_order', array( __CLASS__, 'on_create_order' ), 9999, 2 ); | |
| add_action( 'woocommerce_checkout_order_processed', array( __CLASS__, 'on_order_processed' ), 9999, 3 ); | |
| add_action( 'woocommerce_store_api_checkout_order_processed', array( __CLASS__, 'on_store_api_order_processed' ), 9999, 1 ); | |
| } | |
| /* --------------------------------------------------------------------- | |
| * Hook handlers | |
| * ------------------------------------------------------------------- */ | |
| public static function on_cart_loaded() { | |
| self::log( 'CART_LOADED_FROM_SESSION', array(), true ); | |
| self::sync_baseline(); // seed the diff watcher with whatever the session held | |
| } | |
| public static function on_cart_emptied() { | |
| // Emptying the cart wipes coupons. If this fires mid-checkout it is a | |
| // prime suspect -> full trace. | |
| self::log( 'CART_EMPTIED', array(), true, 'warning' ); | |
| self::$last_coupons = array(); | |
| } | |
| public static function on_cart_updated() { | |
| self::watch_change( 'cart_updated' ); | |
| } | |
| public static function on_before_totals( $cart = null ) { | |
| if ( self::log_totals() ) { | |
| self::log( 'BEFORE_CALCULATE_TOTALS', array(), false, 'debug' ); | |
| } | |
| self::watch_change( 'before_calculate_totals' ); | |
| } | |
| public static function on_after_totals( $cart = null ) { | |
| if ( self::log_totals() ) { | |
| self::log( 'AFTER_CALCULATE_TOTALS', array(), false, 'debug' ); | |
| } | |
| self::watch_change( 'after_calculate_totals' ); | |
| } | |
| public static function on_coupon_applied( $coupon_code ) { | |
| self::log( 'COUPON_APPLIED', array( 'code' => self::clean( $coupon_code ) ), true ); | |
| self::sync_baseline(); | |
| } | |
| public static function on_coupon_removed( $coupon_code ) { | |
| // Explicit removal via the API. The trace tells you who called it. | |
| self::log( 'COUPON_REMOVED', array( 'code' => self::clean( $coupon_code ) ), true, 'warning' ); | |
| self::sync_baseline(); | |
| } | |
| public static function on_update_order_review( $post_data ) { | |
| // $post_data is the serialized checkout form. Do NOT log it raw (PII). | |
| // We only record shape + whether it references a coupon field. | |
| self::log( | |
| 'CHECKOUT_UPDATE_ORDER_REVIEW', | |
| array( | |
| 'post_data_len' => is_string( $post_data ) ? strlen( $post_data ) : null, | |
| 'mentions_coupon' => is_string( $post_data ) && false !== strpos( $post_data, 'coupon' ), | |
| ), | |
| true | |
| ); | |
| self::watch_change( 'update_order_review' ); | |
| } | |
| public static function on_create_order( $order, $data = array() ) { | |
| self::log( | |
| 'CHECKOUT_CREATE_ORDER', | |
| array( 'order_draft' => self::order_coupon_snapshot( $order ) ), | |
| true | |
| ); | |
| } | |
| public static function on_order_processed( $order_id, $posted_data = array(), $order = null ) { | |
| self::finalize_order( $order_id, $order, 'CHECKOUT_ORDER_PROCESSED' ); | |
| } | |
| public static function on_store_api_order_processed( $order ) { | |
| $order_id = ( is_object( $order ) && method_exists( $order, 'get_id' ) ) ? $order->get_id() : 0; | |
| self::finalize_order( $order_id, $order, 'STORE_API_ORDER_PROCESSED' ); | |
| } | |
| /** The verdict: did the placed order keep the coupons the cart had? */ | |
| private static function finalize_order( $order_id, $order, $event ) { | |
| self::log( | |
| $event, | |
| array( | |
| 'order_id' => $order_id, | |
| 'order' => self::order_coupon_snapshot( $order ), | |
| 'mismatch' => self::coupon_mismatch( $order ), | |
| ), | |
| true, | |
| 'warning' | |
| ); | |
| } | |
| /* --------------------------------------------------------------------- | |
| * Diff watcher: catches coupons that vanish WITHOUT a removed_coupon event | |
| * (e.g. cart rebuilt from a coupon-less session, applied_coupons reset | |
| * directly, totals recalculated by a callback that strips them). | |
| * ------------------------------------------------------------------- */ | |
| private static function watch_change( $where ) { | |
| if ( ! function_exists( 'WC' ) || empty( WC()->cart ) ) { | |
| return; | |
| } | |
| $current = self::current_coupons(); | |
| if ( null === self::$last_coupons ) { | |
| self::$last_coupons = $current; | |
| return; | |
| } | |
| if ( $current === self::$last_coupons ) { | |
| return; | |
| } | |
| $removed = array_values( array_diff( self::$last_coupons, $current ) ); | |
| $added = array_values( array_diff( $current, self::$last_coupons ) ); | |
| $lost = ! empty( $removed ); | |
| self::log( | |
| 'COUPON_SET_CHANGED', | |
| array( | |
| 'where' => $where, | |
| 'before' => self::$last_coupons, | |
| 'after' => $current, | |
| 'removed' => $removed, | |
| 'added' => $added, | |
| ), | |
| $lost, // trace only when a coupon was lost | |
| $lost ? 'warning' : 'info' | |
| ); | |
| self::$last_coupons = $current; | |
| } | |
| private static function sync_baseline() { | |
| self::$last_coupons = self::current_coupons(); | |
| } | |
| private static function current_coupons() { | |
| if ( ! function_exists( 'WC' ) || empty( WC()->cart ) ) { | |
| return array(); | |
| } | |
| $c = array_map( 'strval', (array) WC()->cart->get_applied_coupons() ); | |
| sort( $c ); | |
| return $c; | |
| } | |
| /* --------------------------------------------------------------------- | |
| * Snapshots | |
| * ------------------------------------------------------------------- */ | |
| private static function cart_snapshot() { | |
| if ( ! function_exists( 'WC' ) ) { | |
| return array( 'cart' => 'WC() unavailable' ); | |
| } | |
| $wc = WC(); | |
| if ( empty( $wc->cart ) ) { | |
| return array( 'cart' => 'no cart object' ); | |
| } | |
| try { | |
| $cart = $wc->cart; | |
| $applied = array_map( 'strval', (array) $cart->get_applied_coupons() ); | |
| $coupon_detail = array(); | |
| foreach ( $applied as $code ) { | |
| $coupon_detail[ $code ] = array( | |
| 'discount' => $cart->get_coupon_discount_amount( $code ), | |
| 'discount_tax' => $cart->get_coupon_discount_tax_amount( $code ), | |
| ); | |
| } | |
| $totals = method_exists( $cart, 'get_totals' ) ? (array) $cart->get_totals() : array(); | |
| $session_cid = ( $wc->session && method_exists( $wc->session, 'get_customer_id' ) ) | |
| ? $wc->session->get_customer_id() | |
| : null; | |
| return array( | |
| 'applied_coupons' => array_values( $applied ), | |
| 'coupon_count' => count( $applied ), | |
| 'coupon_detail' => $coupon_detail, | |
| 'totals' => array( | |
| 'subtotal' => isset( $totals['subtotal'] ) ? $totals['subtotal'] : null, | |
| 'discount_total' => isset( $totals['discount_total'] ) ? $totals['discount_total'] : null, | |
| 'cart_contents_total' => isset( $totals['cart_contents_total'] ) ? $totals['cart_contents_total'] : null, | |
| 'total' => isset( $totals['total'] ) ? $totals['total'] : null, | |
| ), | |
| 'item_count' => $cart->get_cart_contents_count(), | |
| 'cart_hash' => $cart->get_cart_hash(), | |
| 'session_cid' => $session_cid, | |
| ); | |
| } catch ( \Throwable $e ) { | |
| return array( 'cart_snapshot_error' => $e->getMessage() ); | |
| } catch ( \Exception $e ) { // PHP5 safety net | |
| return array( 'cart_snapshot_error' => $e->getMessage() ); | |
| } | |
| } | |
| private static function order_coupon_snapshot( $order ) { | |
| if ( ! is_object( $order ) || ! method_exists( $order, 'get_total' ) ) { | |
| return array( 'order' => 'not a WC order object' ); | |
| } | |
| try { | |
| if ( method_exists( $order, 'get_coupon_codes' ) ) { | |
| $codes = $order->get_coupon_codes(); // WC 3.7+ | |
| } elseif ( method_exists( $order, 'get_used_coupons' ) ) { | |
| $codes = $order->get_used_coupons(); // legacy | |
| } else { | |
| $codes = array(); | |
| } | |
| return array( | |
| 'coupon_codes' => array_values( array_map( 'strval', (array) $codes ) ), | |
| 'discount_total' => $order->get_discount_total(), | |
| 'subtotal' => method_exists( $order, 'get_subtotal' ) ? $order->get_subtotal() : null, | |
| 'total' => $order->get_total(), | |
| ); | |
| } catch ( \Throwable $e ) { | |
| return array( 'order_snapshot_error' => $e->getMessage() ); | |
| } catch ( \Exception $e ) { | |
| return array( 'order_snapshot_error' => $e->getMessage() ); | |
| } | |
| } | |
| /** The headline comparison: cart coupons vs what landed on the order. */ | |
| private static function coupon_mismatch( $order ) { | |
| $cart_codes = self::current_coupons(); | |
| $order_codes = array(); | |
| if ( is_object( $order ) && method_exists( $order, 'get_coupon_codes' ) ) { | |
| $order_codes = array_map( 'strval', (array) $order->get_coupon_codes() ); | |
| } | |
| sort( $order_codes ); | |
| return array( | |
| 'cart_coupons' => $cart_codes, | |
| 'order_coupons' => array_values( $order_codes ), | |
| 'match' => ( $cart_codes === array_values( $order_codes ) ), | |
| ); | |
| } | |
| /* --------------------------------------------------------------------- | |
| * Logging primitives | |
| * ------------------------------------------------------------------- */ | |
| public static function log( $event, $data = array(), $with_trace = false, $level = 'info' ) { | |
| if ( ! self::enabled() ) { | |
| return; | |
| } | |
| try { | |
| $payload = array( | |
| 'rid' => self::request_id(), | |
| 'cid' => self::customer_id(), | |
| 'ip' => self::client_ip(), | |
| 'event' => $event, | |
| 'ctx' => self::context(), | |
| 'data' => $data, | |
| 'cart' => self::cart_snapshot(), | |
| ); | |
| if ( $with_trace && self::traces_enabled() ) { | |
| $payload['trace'] = self::format_trace(); | |
| } | |
| $json = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR ); | |
| $message = sprintf( '[cid:%s][rid:%s][ip:%s] %s | %s', $payload['cid'], $payload['rid'], $payload['ip'], $event, $json ); | |
| if ( null === self::$logger ) { | |
| self::$logger = wc_get_logger(); | |
| } | |
| self::$logger->log( $level, $message, array( 'source' => self::$source ) ); | |
| } catch ( \Throwable $e ) { | |
| // Logging must never break the request. | |
| } catch ( \Exception $e ) { | |
| // noop | |
| } | |
| } | |
| private static function request_id() { | |
| if ( null === self::$rid ) { | |
| self::$rid = substr( md5( uniqid( (string) wp_rand(), true ) ), 0, 8 ); | |
| } | |
| return self::$rid; | |
| } | |
| /** | |
| * Stable customer fingerprint, cached for the lifetime of the request. | |
| * | |
| * Returns a 12-char hex token. The raw IP and UA are never stored — only | |
| * their SHA-1 digests enter the composite hash, so the log file contains | |
| * no directly identifiable network data. | |
| * | |
| * Resolution order for "who is this customer": | |
| * WC session id > WP user id > hashed IP > hashed UA | |
| * All four are mixed together so the token is maximally stable even when | |
| * one signal is unavailable (e.g. no WC session yet, or a guest user). | |
| */ | |
| /** | |
| * Customer fingerprint (cid) — stable across every request in a session. | |
| * | |
| * Combines the WC session customer id and the WP/WC user id into a short | |
| * token. Use this to group all log lines for one customer across multiple | |
| * request ids. Pair with client_ip() in the log prefix for easy grepping. | |
| */ | |
| private static function customer_id() { | |
| if ( null !== self::$cid ) { | |
| return self::$cid; | |
| } | |
| // WC session customer id — stable anchor across all AJAX round-trips. | |
| $session_cid = ''; | |
| if ( function_exists( 'WC' ) && WC()->session && method_exists( WC()->session, 'get_customer_id' ) ) { | |
| $session_cid = (string) WC()->session->get_customer_id(); | |
| } | |
| // WP / WC user id (0 for guests). | |
| $user_id = function_exists( 'get_current_user_id' ) ? (string) get_current_user_id() : '0'; | |
| self::$cid = substr( md5( $session_cid . '|' . $user_id ), 0, 12 ); | |
| return self::$cid; | |
| } | |
| /** | |
| * Raw client IP, resolved through common proxy / CDN headers in priority order: | |
| * Cloudflare (CF-Connecting-IP) > X-Forwarded-For (leftmost) > X-Real-IP > REMOTE_ADDR | |
| * | |
| * Logged as-is so you can grep logs by IP directly. | |
| */ | |
| private static function client_ip() { | |
| foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ) as $key ) { | |
| if ( ! empty( $_SERVER[ $key ] ) ) { | |
| $value = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); | |
| return trim( explode( ',', $value )[0] ); | |
| } | |
| } | |
| return ''; | |
| } | |
| private static function context() { | |
| $action = ''; | |
| if ( isset( $_REQUEST['wc-ajax'] ) ) { | |
| $action = 'wc-ajax:' . sanitize_text_field( wp_unslash( $_REQUEST['wc-ajax'] ) ); | |
| } elseif ( isset( $_REQUEST['action'] ) ) { | |
| $action = 'action:' . sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ); | |
| } | |
| return array( | |
| 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '', | |
| 'action' => $action, | |
| 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', | |
| 'ajax' => function_exists( 'wp_doing_ajax' ) ? wp_doing_ajax() : false, | |
| 'rest' => function_exists( 'wp_is_serving_rest_request' ) ? wp_is_serving_rest_request() : null, | |
| 'is_checkout' => ( function_exists( 'is_checkout' ) && did_action( 'wp' ) ) ? is_checkout() : null, | |
| 'user' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, | |
| ); | |
| } | |
| /** | |
| * Condensed, argument-free backtrace. DEBUG_BACKTRACE_IGNORE_ARGS keeps it | |
| * cheap and free of PII/large objects. Our own frames are stripped so the | |
| * first line is the real caller into WooCommerce. | |
| */ | |
| private static function format_trace( $limit = 30 ) { | |
| $frames = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, $limit ); | |
| $out = array(); | |
| foreach ( $frames as $f ) { | |
| if ( isset( $f['file'] ) && __FILE__ === $f['file'] ) { | |
| continue; // skip the logger plumbing | |
| } | |
| $fn = ''; | |
| if ( isset( $f['class'] ) ) { | |
| $fn = $f['class'] . ( isset( $f['type'] ) ? $f['type'] : '::' ) . $f['function']; | |
| } elseif ( isset( $f['function'] ) ) { | |
| $fn = $f['function']; | |
| } | |
| $loc = ''; | |
| if ( isset( $f['file'] ) ) { | |
| $loc = self::short_path( $f['file'] ) . ':' . ( isset( $f['line'] ) ? $f['line'] : '?' ); | |
| } | |
| $out[] = trim( $fn . ' (' . $loc . ')' ); | |
| } | |
| return $out; | |
| } | |
| /** Shorten an absolute path to wp-content/... without reading any constant. */ | |
| private static function short_path( $path ) { | |
| $path = str_replace( '\\', '/', (string) $path ); | |
| $pos = strpos( $path, '/wp-content/' ); | |
| if ( false !== $pos ) { | |
| return substr( $path, $pos + 1 ); | |
| } | |
| return $path; | |
| } | |
| private static function clean( $value ) { | |
| return is_scalar( $value ) ? sanitize_text_field( (string) $value ) : ''; | |
| } | |
| } | |
| add_action( 'plugins_loaded', array( 'FB_Coupon_Diagnostics', 'init' ), 99 ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment