0, 'CLP' => 0, 'DJF' => 0, 'GNF' => 0, 'ISK' => 0, 'JPY' => 0, 'KMF' => 0, 'KRW' => 0, 'PYG' => 0, 'RWF' => 0, 'UGX' => 0, 'UYI' => 0, 'VND' => 0, 'VUV' => 0, 'XAF' => 0, 'XOF' => 0, 'XPF' => 0, // Exponent 3. 'BHD' => 3, 'IQD' => 3, 'JOD' => 3, 'KWD' => 3, 'LYD' => 3, 'OMR' => 3, 'TND' => 3, // Exponent 4. // CLF and UYW are ISO exponent 4. The ingest contract caps the exponent // at 3 (`currency_exponent BETWEEN 0 AND 3` in the commerce ledger), so // emitting 4 produces an event the server refuses. They are carried at 3 // here, which loses the last decimal place of a unit that is an // accounting index rather than a price anyone charges. Sending a // slightly rounded value beats sending nothing. 'CLF' => 3, 'UYW' => 3, ); /** * The canonical site identity used in the envelope AND in the event_id hash. * * Lane B must print this exact string into the browser config, or the two * halves of a purchase will hash to different event_ids and every * conversion will be counted twice. * * @return string */ public static function site_url() { $url = untrailingslashit( home_url() ); /** * Filter the site identity used for the envelope and the event_id hash. * * @param string $url Untrailingslashed home URL. */ return (string) apply_filters( 'advisorppc_site_url', $url ); } /** * The dedup key. * * CONTRACT.md: "wp_". * * Implemented literally as: * 'wp_' . sha1( site_url . '|' . event_name . '|' . $ref ) * * where $ref is the order reference ("wc_1234") for order-scoped events and * the session id for session-scoped events. * * @param string $event_name Contract event name. * @param string $ref Order reference or session id. * @return string */ public static function event_id( $event_name, $ref ) { // Lowercased and trimmed, because AdvisorPPC_Tags::event_id() does the // same before delegating here. Without it, event_id( 'Purchase', ... ) // and event_id( 'purchase', ... ) hash differently and the browser and // server halves of one conversion stop matching. $event_name = strtolower( trim( (string) $event_name ) ); $ref = trim( (string) $ref ); return 'wp_' . sha1( self::site_url() . '|' . $event_name . '|' . $ref ); } /** * Lowercase, trim, SHA-256 hex. The only way PII may leave this plugin. * * @param mixed $value Raw value. * @return string 64 hex characters, or '' when there was nothing to hash. */ public static function hash( $value ) { if ( ! is_scalar( $value ) ) { return ''; } $value = trim( (string) $value ); if ( '' === $value ) { return ''; } if ( function_exists( 'mb_strtolower' ) ) { $value = mb_strtolower( $value, 'UTF-8' ); } else { $value = strtolower( $value ); } $value = trim( $value ); if ( '' === $value ) { return ''; } return hash( 'sha256', $value ); } /** * Normalise then hash the eight contract user_data fields. * * Input keys accepted: em, ph, fn, ln, ct, st, zp, country. * Output contains only keys that hashed to exactly 64 hex characters, which * is what lane D validates. * * @param array $raw Raw, unhashed values. * @return array Hashed values. */ public static function hash_user_data( $raw ) { if ( ! is_array( $raw ) ) { return array(); } $out = array(); foreach ( array( 'em', 'ph', 'fn', 'ln', 'ct', 'st', 'zp', 'country' ) as $key ) { if ( ! isset( $raw[ $key ] ) ) { continue; } $value = self::normalize_pii( $key, $raw[ $key ] ); if ( '' === $value ) { continue; } $hashed = self::hash( $value ); if ( preg_match( '/^[a-f0-9]{64}$/', $hashed ) ) { $out[ $key ] = $hashed; } } return $out; } /** * Field-specific normalisation applied before the lowercase/trim/SHA-256 step. * * This only ever removes formatting noise; it never invents a value. * * @param string $key Field key. * @param mixed $value Raw value. * @return string */ private static function normalize_pii( $key, $value ) { if ( ! is_scalar( $value ) ) { return ''; } $value = trim( (string) $value ); if ( '' === $value ) { return ''; } switch ( $key ) { case 'em': // Emails carry no internal whitespace. $value = preg_replace( '/\s+/', '', $value ); break; case 'ph': // Digits only, the form every conversions API expects. $value = preg_replace( '/[^0-9]/', '', $value ); break; case 'zp': $value = preg_replace( '/\s+/', '', $value ); // US ZIP+4 matches on the first five. if ( preg_match( '/^([0-9]{5})-?[0-9]{4}$/', $value, $m ) ) { $value = $m[1]; } break; case 'country': $value = preg_replace( '/[^A-Za-z]/', '', $value ); if ( strlen( $value ) > 2 ) { $value = substr( $value, 0, 2 ); } break; case 'ct': case 'st': case 'fn': case 'ln': default: $value = preg_replace( '/\s+/', ' ', $value ); break; } return is_string( $value ) ? trim( $value ) : ''; } /** * Minor-unit exponent for a currency. * * Deliberately NOT WooCommerce's price decimals, which is a display setting. * * @param string $currency ISO 4217 code. * @return int */ public static function currency_exponent( $currency ) { $currency = strtoupper( preg_replace( '/[^A-Za-z]/', '', (string) $currency ) ); $map = self::CURRENCY_EXPONENTS; $exponent = isset( $map[ $currency ] ) ? (int) $map[ $currency ] : 2; /** * Filter the minor-unit exponent for a currency. * * @param int $exponent Exponent. * @param string $currency Uppercase ISO 4217 code. */ $filtered = (int) apply_filters( 'advisorppc_currency_exponent', $exponent, $currency ); // The ingest refuses anything outside 0..3 (currency_exponent BETWEEN 0 // AND 3 in the commerce ledger), and a refused event is logged and // dropped, so a filter that returns 4 for a four-decimal currency would // lose every sale in that currency rather than one decimal place. An // out-of-range filter value falls back to the table's own exponent, // which is always inside the contract range. if ( $filtered >= 0 && $filtered <= 3 ) { $exponent = $filtered; } return $exponent; } /** * Convert a major-unit amount to integer minor units. * * Never returns a float. 49.99 USD becomes the integer 4999; 5000 JPY * becomes the integer 5000. * * @param mixed $amount Major-unit amount, numeric string or float. * @param string $currency ISO 4217 code. * @return int */ public static function to_minor( $amount, $currency ) { if ( is_string( $amount ) ) { $amount = trim( str_replace( ',', '', $amount ) ); } if ( ! is_numeric( $amount ) ) { return 0; } $exponent = self::currency_exponent( $currency ); $factor = pow( 10, $exponent ); // round() before the cast; (int) on a float truncates and would lose a cent. return (int) round( ( (float) $amount ) * $factor ); } /** * Uppercase, sanitised currency code with a store fallback. * * @param string $currency Raw code. * @return string */ public static function currency( $currency = '' ) { $currency = strtoupper( preg_replace( '/[^A-Za-z]/', '', (string) $currency ) ); if ( 3 !== strlen( $currency ) && function_exists( 'get_woocommerce_currency' ) ) { $currency = strtoupper( preg_replace( '/[^A-Za-z]/', '', (string) get_woocommerce_currency() ) ); } if ( 3 !== strlen( $currency ) ) { $currency = 'USD'; } return $currency; } /** * Visitor session id, shared with the browser half. * * Prefers lane B's accessor if it exposes one, then the first-party cookie, * then nothing. A missing session id is not fatal: order-scoped events do * not use it. * * @return string */ public static function session_id() { $sid = ''; if ( class_exists( 'AdvisorPPC_ClickIDs' ) && method_exists( 'AdvisorPPC_ClickIDs', 'get_session_id' ) ) { $sid = (string) call_user_func( array( 'AdvisorPPC_ClickIDs', 'get_session_id' ) ); } if ( '' === $sid ) { foreach ( array( 'advisorppc_sid', 'advisorppc_session', '_advisorppc_sid' ) as $cookie ) { // phpcs:ignore WordPress.Security.NonceVerification.Recorded -- read-only cookie inspection, not a form submission. if ( isset( $_COOKIE[ $cookie ] ) ) { $sid = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie ] ) ); break; } } } $sid = preg_replace( '/[^A-Za-z0-9._\-]/', '', (string) $sid ); if ( strlen( $sid ) > 64 ) { $sid = substr( $sid, 0, 64 ); } /** * Filter the session id used for session-scoped event ids. * * @param string $sid Session id. */ return (string) apply_filters( 'advisorppc_session_id', $sid ); } /** * The nine click IDs, always all nine keys, null where absent. * * @param array $override Pre-captured click IDs, e.g. replayed from order meta. * @return array */ public static function click_ids( $override = null ) { $raw = array(); if ( is_array( $override ) ) { $raw = $override; } elseif ( class_exists( 'AdvisorPPC_ClickIDs' ) ) { // get_click_ids() is lane B's contract-shaped accessor; get_all() // is the wider bundle and is accepted as a fallback. foreach ( array( 'get_click_ids', 'get_all' ) as $method ) { if ( ! method_exists( 'AdvisorPPC_ClickIDs', $method ) ) { continue; } $maybe = call_user_func( array( 'AdvisorPPC_ClickIDs', $method ) ); if ( is_array( $maybe ) ) { $raw = $maybe; break; } } } $out = array(); foreach ( self::CLICK_ID_KEYS as $key ) { $value = isset( $raw[ $key ] ) ? $raw[ $key ] : null; if ( ! is_scalar( $value ) ) { $out[ $key ] = null; continue; } // '~' is kept. It is legal in a gclid and load-bearing in Meta's // fbp/fbc format, and both the JS capture (public/js/clickids.js) // and AdvisorPPC_ClickIDs allow it. Stripping it here corrupted the // identifier on its way out, which is worse than dropping it: the // platform accepts a mangled click id and matches nothing. $value = preg_replace( '/[^A-Za-z0-9._~\-]/', '', (string) $value ); if ( '' === $value ) { $out[ $key ] = null; continue; } $out[ $key ] = substr( $value, 0, 512 ); } return $out; } /** * Consent state, when lane B can tell us. Omitted entirely when unknown: * guessing 'granted' would be a privacy lie and guessing 'denied' would * make lane D drop legitimate conversions. * * @return array|null */ public static function consent() { $state = null; // The method is visitor_state(). An earlier draft called get_state(), // which has never existed, so method_exists() was always false and every // server event went out with NO consent block: a visitor who declined in // the banner still had their conversion sent to the platforms. Both // names are accepted here so a site filtering the class cannot break it // the same way twice. foreach ( array( 'visitor_state', 'get_state' ) as $method ) { if ( class_exists( 'AdvisorPPC_Consent' ) && method_exists( 'AdvisorPPC_Consent', $method ) ) { $maybe = call_user_func( array( 'AdvisorPPC_Consent', $method ) ); if ( is_array( $maybe ) ) { $state = $maybe; break; } } } /** * Filter the consent block attached to server events. * * @param array|null $state Consent state or null when unknown. */ $state = apply_filters( 'advisorppc_event_consent', $state ); if ( ! is_array( $state ) ) { return null; } $out = array(); foreach ( array( 'ad_storage', 'analytics_storage' ) as $key ) { if ( ! isset( $state[ $key ] ) ) { continue; } $value = strtolower( sanitize_text_field( (string) $state[ $key ] ) ); if ( 'granted' === $value || 'denied' === $value ) { $out[ $key ] = $value; } } return empty( $out ) ? null : $out; } /** * Page context for the current request. Empty in admin and cron contexts. * * @return array */ public static function page() { $page = array( 'url' => '', 'referrer' => '', 'title' => '', ); if ( is_admin() || ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) ) { return $page; } if ( isset( $_SERVER['REQUEST_URI'] ) ) { $uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ); $page['url'] = esc_url_raw( home_url( $uri ) ); } if ( isset( $_SERVER['HTTP_REFERER'] ) ) { $page['referrer'] = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ); } if ( did_action( 'template_redirect' ) && function_exists( 'wp_get_document_title' ) ) { $page['title'] = wp_strip_all_tags( wp_get_document_title() ); } return $page; } /** * Build one contract event. * * Recognised $args keys: * ref string order reference or session id, drives event_id * order_ref string 'wc_' when order scoped * value mixed major-unit amount * value_minor int pre-computed minor units, wins over `value` * currency string ISO 4217 * occurred_at int unix seconds, defaults to now * session_id string overrides the detected session * click_ids array overrides the detected click IDs * user_data array RAW PII, hashed here, never transmitted raw * user_data_hashed array already-hashed values, used verbatim * page array overrides page context * consent array overrides consent * * @param string $event_name Contract event name. * @param array $args See above. * @return array|null Event array, or null when the input was unusable. */ public static function build( $event_name, $args = array() ) { $event_name = sanitize_key( $event_name ); if ( ! in_array( $event_name, self::NAMES, true ) ) { return null; } $args = is_array( $args ) ? $args : array(); $session_id = isset( $args['session_id'] ) ? (string) $args['session_id'] : self::session_id(); $session_id = preg_replace( '/[^A-Za-z0-9._\-]/', '', $session_id ); $order_ref = isset( $args['order_ref'] ) ? sanitize_text_field( (string) $args['order_ref'] ) : ''; // The event_id reference: order scoped events use the order reference, // everything else uses the session id. if ( isset( $args['ref'] ) && '' !== (string) $args['ref'] ) { $ref = (string) $args['ref']; } elseif ( '' !== $order_ref ) { $ref = $order_ref; } else { $ref = $session_id; } if ( '' === $ref ) { // Without a stable reference we cannot produce a dedup key, and an // event that cannot be deduped is worse than no event at all. return null; } $occurred_at = isset( $args['occurred_at'] ) ? (int) $args['occurred_at'] : time(); if ( $occurred_at <= 0 ) { $occurred_at = time(); } $event = array( 'event_id' => self::event_id( $event_name, $ref ), 'event_name' => $event_name, 'occurred_at' => $occurred_at, ); $has_value = isset( $args['value_minor'] ) || isset( $args['value'] ); if ( $has_value ) { $currency = self::currency( isset( $args['currency'] ) ? $args['currency'] : '' ); if ( isset( $args['value_minor'] ) ) { $minor = (int) $args['value_minor']; } else { $minor = self::to_minor( $args['value'], $currency ); } $event['value_minor'] = $minor; $event['currency'] = $currency; $event['currency_exponent'] = self::currency_exponent( $currency ); } if ( '' !== $order_ref ) { $event['order_ref'] = $order_ref; } // Omit the key entirely rather than send an empty string. The server // treats session_id as OPTIONAL but rejects '' as a malformed // identifier, and a rejected event is returned inside a 200, which the // queue reads as success and then deletes. A purchase from a visitor // with no cookie (a gateway IPN, a consent-blocked first hit) would be // silently lost. The event_id does not depend on this field when an // order reference exists, so nothing about dedup changes. if ( '' !== $session_id ) { $event['session_id'] = $session_id; } $event['click_ids'] = self::click_ids( isset( $args['click_ids'] ) ? $args['click_ids'] : null ); $user_data = array(); if ( isset( $args['user_data'] ) && is_array( $args['user_data'] ) ) { $user_data = self::hash_user_data( $args['user_data'] ); } if ( isset( $args['user_data_hashed'] ) && is_array( $args['user_data_hashed'] ) ) { foreach ( $args['user_data_hashed'] as $key => $value ) { $key = sanitize_key( $key ); $value = is_scalar( $value ) ? strtolower( (string) $value ) : ''; if ( preg_match( '/^[a-f0-9]{64}$/', $value ) ) { $user_data[ $key ] = $value; } } } $event['user_data'] = $user_data; $page = isset( $args['page'] ) && is_array( $args['page'] ) ? $args['page'] : self::page(); $event['page'] = array( 'url' => isset( $page['url'] ) ? esc_url_raw( (string) $page['url'] ) : '', 'referrer' => isset( $page['referrer'] ) ? esc_url_raw( (string) $page['referrer'] ) : '', 'title' => isset( $page['title'] ) ? wp_strip_all_tags( (string) $page['title'] ) : '', ); $consent = isset( $args['consent'] ) && is_array( $args['consent'] ) ? $args['consent'] : self::consent(); if ( is_array( $consent ) && ! empty( $consent ) ) { $event['consent'] = $consent; } /** * Filter a built event immediately before it is queued. * * Returning a non-array cancels the event. * * @param array $event Contract event. * @param string $event_name Event name. * @param array $args Original arguments. */ $event = apply_filters( 'advisorppc_build_event', $event, $event_name, $args ); return is_array( $event ) ? $event : null; } /** * Wrap events in the contract envelope. * * @param array $events List of events. * @return array */ public static function envelope( $events ) { return array( 'site_url' => self::site_url(), 'sent_at' => time(), 'events' => array_values( is_array( $events ) ? $events : array() ), ); } /** * Whether server-side events may be produced at all. * * Guideline 7 and CONTRACT.md non-negotiable 5: nothing leaves the site * before the admin has connected and a key pair exists. * * @return bool */ public static function is_enabled() { if ( ! class_exists( 'AdvisorPPC_Options' ) || ! class_exists( 'AdvisorPPC_Signer' ) ) { return false; } if ( ! AdvisorPPC_Options::get( 'connected', false ) ) { return false; } if ( ! AdvisorPPC_Signer::is_configured() ) { return false; } if ( method_exists( 'AdvisorPPC_Options', 'module_enabled' ) ) { if ( ! AdvisorPPC_Options::module_enabled( 'server_events' ) ) { return false; } } else { $modules = AdvisorPPC_Options::get( 'modules', array() ); if ( is_array( $modules ) && array_key_exists( 'server_events', $modules ) && ! $modules['server_events'] ) { return false; } } /** * Filter whether server-side events are produced. * * @param bool $enabled Enabled. */ return (bool) apply_filters( 'advisorppc_server_events_enabled', true ); } /** * Build an event and hand it to the durable queue. * * Never performs a network call, never blocks the request it was fired * from. A checkout completes whether or not the API is reachable. * * @param string $event_name Contract event name. * @param array $args See build(). * @return string|false The event_id on success, false otherwise. */ public static function record( $event_name, $args = array() ) { if ( ! self::is_enabled() ) { return false; } $event = self::build( $event_name, $args ); if ( ! is_array( $event ) || empty( $event['event_id'] ) ) { return false; } if ( ! class_exists( 'AdvisorPPC_Queue' ) ) { return false; } // Local bookkeeping, never part of the envelope: whether the browser // half of this conversion was produced. CONTRACT.md owns the wire // shape, so this rides on the queue row instead. $browser_half = isset( $args['browser_half'] ) ? (string) $args['browser_half'] : ''; if ( ! AdvisorPPC_Queue::add( $event, $browser_half ) ) { return false; } /** * Fires after an event has been durably queued. * * Lane B can use this to mirror the identical event_id into the browser * payload. * * @param array $event Contract event. */ do_action( 'advisorppc_event_recorded', $event ); return $event['event_id']; } }