Skip to content

Instantly share code, notes, and snippets.

@xlplugins
Created June 12, 2026 07:59
Show Gist options
  • Select an option

  • Save xlplugins/772fc6085be6d4bda04ba1e2dd5b7ea7 to your computer and use it in GitHub Desktop.

Select an option

Save xlplugins/772fc6085be6d4bda04ba1e2dd5b7ea7 to your computer and use it in GitHub Desktop.
FB_coupon_diagnostics.php
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