Seguimiento de conversiones de SureCart
SureCart es el caso raro entre los plugins de commerce que Beaconry soporta. WooCommerce y Easy Digital Downloads disparan actions de PHP para casi cada paso del embudo, así que el seguimiento es puramente del lado del servidor. SureCart dispara su embudo de carrito como CustomEvents del navegador sobre el document, sin un submit nativo de PHP al que engancharse. Este artículo recorre cómo Beaconry maneja esa división: un puente de navegador para los eventos de carrito impulsados por JS, y hooks autoritativos del lado del servidor para la compra y el reembolso, todos deduplicados sobre un event_id por pedido.
Por qué SureCart necesita un enfoque distinto
SureCart está construido sobre la WordPress Interactivity API y un frontend de bloques al estilo de React. El carrito, las vistas de producto, los pasos del checkout, todo eso corre en el navegador y habla con la propia REST API de SureCart. No hay un POST de formulario clásico, ni un renderizado de página template_redirect al que puedas aferrarte para "el visitante acaba de ver este producto". Desde el punto de vista de WordPress-PHP, la mayor parte del embudo es invisible: los únicos momentos que afloran del lado del servidor son los que SureCart emite explícitamente como actions, y para el embudo de carrito no emite ninguno.
Lo que SureCart hace en su lugar es despachar CustomEvents sobre document. Cuando un visitante ve un producto, lo añade al carrito, abre el carrito o avanza por el checkout, SureCart dispara eventos como scProductViewed, scAddedToCart y scCheckoutInitiated. Cada uno lleva un payload detail con los datos del producto o del pedido. Esa es la señal con la que Beaconry tiene que trabajar para el embudo de carrito, así que Beaconry escucha esos eventos en el navegador y los reenvía a través del mismísimo endpoint de mismo origen que usa cualquier otro evento de Beaconry.
El resultado es una división limpia. Los eventos del embudo de carrito (los impulsados por JS) entran por un puente de navegador. La compra y el reembolso (los que mueven dinero y llevan PII) entran por hooks reales de PHP de SureCart. Ambos caminos aterrizan en el mismo fan-out de BCNR_Forwarder::dispatch(), así que desde el lado del canal (GA4, Meta, TikTok, el resto) no hay diferencia en cómo se trata el evento.
El puente de navegador: nueve eventos de carrito
El motor de frontend de Beaconry (el nl-data.js vendorizado) registra un puente de SureCart que adjunta nueve handlers de document.addEventListener. Cada uno mapea un CustomEvent de SureCart a un evento canónico de Beaconry (con nombre de GA4):
scProductsViewed→view_item_list(páginas de tienda y de colección)scProductViewed→view_itemscSearched→searchscAddedToCart→add_to_cartscRemovedFromCart→remove_from_cartscViewedCart→view_cartscCheckoutInitiated→begin_checkoutscPaymentInfoAdded→add_payment_infoscShippingInfoAdded→add_shipping_info
El handler lee el detail del CustomEvent, lo remodela en el array estándar items[] de Beaconry más el bloque de catálogo de Meta (content_ids, content_type, content_name, num_items), y se lo entrega a sendEvent(). Ese es el mismo camino de entrega que usan los eventos de page-view y de click: un POST a /wp-json/beaconry/v1/event en tu propio dominio, sin script de tracker de terceros, nada que un adblocker pueda reconocer por el contenido. Desde ahí el servidor normaliza el nombre del evento y lo reparte a cada canal configurado.
Un detalle que importa para la precisión: el bus de window-event de SureCart lleva varias formas de detail distintas. Una vista de lista envía { products: [...] }. Una página de producto individual envía un { id, name, price: {...} } plano. Un add-to-cart envía un único line-item con un price.product anidado. Los eventos de paso de checkout envían una forma de pedido con line_items.data[] y un total_amount. El puente tiene un pequeño convertidor por forma para que item_id siempre resuelva al id de producto real de SureCart (no a un id de line-item, que rompería el match de catálogo), y item_category se compone a partir de las colecciones del producto. Equivocarse con item_id aquí es la forma clásica en que los Dynamic Product Ads dejan de hacer match en silencio, así que cada forma se maneja explícitamente en lugar de adivinarse.
Timing: por qué el puente se adjunta temprano
La mayoría de los listeners de frontend de Beaconry se adjuntan dentro de requestIdleCallback para mantenerse fuera de la ruta crítica de renderizado. El puente de SureCart es la excepción deliberada. SureCart despacha scProductViewed y scProductsViewed desde su callback de init() de wp-interactivity, justo en DOM-ready. Si Beaconry solo adjuntara sus listeners hasta dos segundos más tarde en tiempo idle, se perdería por completo el evento inicial de vista de producto en una página de producto.
Así que el puente se registra de forma síncrona en el init, antes del bloque de tiempo idle:
// Pageview and engagement timer are time-sensitive - fire immediately.
if ( a.pageview ) setupPageview();
if ( a.engagement ) setupEngagement();
// SureCart V4 dispatches scProductViewed / scProductsViewed in the
// wp-interactivity init() callback right at DOM-ready. If our listeners
// only attach in requestIdleCallback (up to 2s later), we miss the
// initial events. addEventListener is free; no LCP impact.
if ( _config.surecartBridge ) setupSureCartBridge();Adjuntar un event listener es esencialmente gratis, no hace trabajo de DOM y no tiene coste de LCP medible, así que hacerlo temprano compra corrección sin un sacrificio de rendimiento. El flag de config del puente además está siempre activo. Nueve llamadas idle a addEventListener en un sitio sin SureCart cuestan menos de un kilobyte minificadas y nunca disparan, así que no hay razón para encerrarlas tras una comprobación de detección de plugin del lado del servidor que podría equivocar el orden de carga.
Compra: del lado del servidor, no el puente
Notarás que scCheckoutCompleted no está en la lista del puente. Eso es a propósito. La compra es el único evento donde la calidad del match importa más, y el CustomEvent del navegador solo lleva datos del carrito, ninguna PII del cliente. Disparar la compra desde el navegador enviaría una conversión sin email hasheado, sin teléfono, sin dirección de facturación, que es exactamente la data que impulsa el match-rate de Meta y Google Ads.
Así que la compra corre de forma autoritativa en PHP, sobre la action surecart/checkout_confirmed de SureCart:
add_action( 'surecart/checkout_confirmed', [ __CLASS__, 'handle_checkout_confirmed' ], 10, 2 );Esto dispara en el momento en que se confirma un checkout, independientemente del procesador de pago (Stripe, una transferencia bancaria manual, contra reembolso). El momento de la conversión es la confirmación, el vinculante "estoy comprando esto", no la liquidación posterior del cargo. Eso significa que un pedido realizado por email o un cargo manual de admin igual cuenta, aunque nunca haya corrido un pipeline de JS del navegador para él.
Hay una arista afilada aquí que vale la pena señalar, porque es el tipo de cosa que silenciosamente le arranca la PII a cada compra si se te escapa: el objeto $checkout que se entrega al hook no tiene sus relaciones cargadas de forma eager. Léelo tal cual y customer y line_items vuelven vacíos, así que la compra despacharía sin PII y sin content_ids. Beaconry recarga el checkout explícitamente con las relaciones que necesita:
$full = \SureCart\Models\Checkout::with( [
'customer',
'customer.billing_address',
'line_items',
'line_items.price',
'price.product',
'product.product_collections',
] )->find( $checkout_id );Las rutas de relación se ven raramente fragmentadas, y eso también es deliberado. El ::with() de SureCart limita la expansión a dos niveles por entrada. Una sola ruta profunda como line_items.price.product (tres niveles) silenciosamente no carga. Así que al producto hay que llegar mediante entradas separadas de dos niveles ancladas en el último nodo (price.product, luego product.product_collections), de lo contrario item_id recae en el id del line-item y el nombre y la categoría del producto llegan vacíos. Este es el tipo de comportamiento específico del vendor que solo aciertas leyendo los propios docs y el source de SureCart, no asumiendo que una ruta profunda con puntos resuelve como lo haría en la mayoría de los ORMs.
Con las relaciones cargadas, el handler construye un snapshot normalizado: id de pedido, importe, moneda, el bloque de cliente (email, nombre y apellido, teléfono, ciudad, provincia, código postal, país) y los line items. Desde ahí produce tanto la forma items[] de GA4 como el bloque de catálogo de Meta, no hashea nada por sí mismo (el forwarder hace el hasheo de PII por canal) y despacha.
Céntimos, y las monedas que no van en céntimos
SureCart sigue la convención de Stripe: los importes son enteros en la unidad de moneda más pequeña. Un pedido de 49,00 EUR llega como 4900. Beaconry divide entre 100 para obtener el valor de unidad mayor que reporta como valor de conversión.
Excepto para las monedas de cero decimales. JPY, KRW y un puñado de otras no tienen unidad menor, así que un pedido de 500 yenes llega como 500, ya en unidades mayores. Dividir esos entre 100 reportaría la conversión al uno por ciento de su valor real. Beaconry mantiene la lista de monedas de cero decimales de Stripe y omite la división para esos códigos:
private static function amount_to_major( float $amount, string $currency ): float {
$code = strtoupper( trim( $currency ) );
if ( $code !== '' && in_array( $code, self::ZERO_DECIMAL_CURRENCIES, true ) ) {
return $amount;
}
return $amount / 100.0;
}La misma lógica existe en el lado del navegador del puente para los valores del embudo de carrito, porque esos importes también vienen de SureCart en céntimos. Una versión anterior usaba una heurística condicional de "dividir solo si el entero es lo bastante grande", que reportaba pedidos pequeños por debajo de una unidad (un pedido de 99 céntimos) como 99 de la moneda. Leer los docs de SureCart confirmó que los importes siempre están en céntimos, así que la heurística se reemplazó por la conversión incondicional y consciente de cero decimales de arriba.
Reembolso: el hook correcto, y dos equivocados
El reembolso es solo de GA4. Beaconry compensa el reembolso contra la compra original en el reporting de ingresos de GA4 y omite los canales de ads, porque ninguno de Meta, TikTok, Google Ads ni los demás tiene un evento real de conversión de reembolso al que enviarlo. Esa parte es compartida entre los tres adaptadores de commerce.
Lo que es específico de SureCart es qué hook dispara realmente en un reembolso, y aquí los candidatos obvios están ambos equivocados:
surecart/refund_createdno existe. Es un hook fantasma, una action que razonablemente esperarías por analogía con los otros modelos, pero SureCart nunca la dispara.surecart/purchase_revokedes la señal equivocada. Dispara solo cuando un reembolso revoca el acceso (el flag de revoke-purchase en el reembolso), no en un reembolso monetario sin más. Un reembolso parcial que deja el acceso intacto nunca lo activa.
El hook que sí dispara en cada reembolso es el evento genérico de modelo creado que SureCart emite desde su ruta base Model::create():
add_action( 'surecart/models/refund/created', [ __CLASS__, 'handle_refund_created' ], 10, 1 );Esto dispara para cada reembolso, desde cualquier procesador, ya sea activado desde la UI de admin o la REST API, sin webhook y sin requerir revocación de acceso. El objeto $refund lleva su propio importe en céntimos, su propia moneda y una referencia al cargo que reembolsa. Como Beaconry lee el propio importe del reembolso (no el total del pedido), los reembolsos parciales se reportan con el valor parcial correcto, pasados por la misma conversión consciente de cero decimales que la compra.
Este es un caso de manual para la regla de "lee los docs y el source, no adivines". Los primeros dos nombres de hook se ven correctos y pasarían un code-review superficial. Solo comprobar el comportamiento real de eventos de modelo de SureCart revela que el genérico surecart/models/refund/created es el que dispara de forma fiable.
Dedup de event_id por pedido
Ambos eventos del lado del servidor llevan un event_id determinista, derivado del id del objeto de SureCart:
- Compra:
bcnr_sc_purchase_<checkout_id> - Reembolso:
bcnr_sc_refund_<refund_id>
Determinista, no aleatorio, porque el mismo valor tiene que sobrevivir a un reintento. Si el hook checkout_confirmed corre dos veces (un re-fire, un cambio de estado en el admin), o un cliente recarga la página de confirmación, el event_id es idéntico. Del lado del vendor eso significa que la plataforma deduplica el par y cuenta una conversión. Beaconry además escribe una opción de marcador local indexada por el id (bcnr_sc_purchase_fired_<hash>) y hace cortocircuito si ya está puesta, así que un disparo de hook duplicado ni siquiera llega al dispatcher una segunda vez. Dos capas, un conteo.
Este es el mismo mecanismo de event_id que impulsa el modo híbrido. Si corres un pixel de navegador junto al dispatch del lado del servidor, Beaconry refleja la compra a los pixels cargados con el mismísimo event_id que usó la CAPI del servidor, así que el par navegador-a-servidor se deduplica en cada plataforma. El artículo sobre el modo híbrido cubre ese camino en detalle.
Una limitación honesta: un reembolso de SureCart enlaza a un cargo, mientras que la compra se indexa por el id de checkout. No comparten un id de transacción, así que GA4 compensará los ingresos reembolsados a nivel de cuenta pero no emparejará automáticamente un reembolso específico con su fila de pedido original específica. Eso está documentado como trabajo futuro en lugar de tapado.
Una cosa que el puente no hace: disparar un abandono falso
Beaconry tiene una funcionalidad de embudo de formulario aparte que rastrea dónde abandonan los visitantes los formularios de lead. Los checkouts de commerce están excluidos de ella explícitamente. Un checkout de SureCart no es un formulario de lead, tiene su propio embudo de vista-a-begin_checkout-a-compra, y su submit es invisible para el embudo de formulario de todas formas (es REST y JS, sin submit nativo). Rastrearlo como un formulario dispararía por error un form_abandon en cada compra completada. Beaconry impone la exclusión desde el lado del servidor (el flag de exclusión agnóstico al render, puesto cuando la página es un checkout o carrito de commerce) y como respaldo de clase CSS en nl-data.js que reconoce las clases de componente de checkout y carrito de SureCart. El abandono en un paso de checkout es tarea del embudo de commerce, un begin_checkout sin un purchase posterior, no del embudo de formulario.
Conclusión
SureCart se divide limpiamente en dos caminos de seguimiento y Beaconry sigue la división. El embudo de carrito impulsado por JS (nueve eventos desde view_item_list hasta add_payment_info) entra por un puente de navegador que escucha los CustomEvents de SureCart y los enruta por el mismo endpoint de mismo origen que todo lo demás, adjuntándose temprano para no perderse las vistas de producto que SureCart dispara en el init. La compra y el reembolso corren del lado del servidor sobre hooks reales de SureCart: surecart/checkout_confirmed para la compra (recargada con las relaciones correctas de dos niveles para que la PII y los ids de producto realmente estén ahí), y el genérico surecart/models/refund/created para el reembolso (el único hook que dispara en cada reembolso, no los dos que se ven correctos y no lo son). Los importes se convierten desde céntimos de Stripe con una excepción de cero decimales, y un event_id determinista por pedido impide que los reintentos y los pixels de navegador del modo híbrido cuenten doble. La única configuración del lado de SureCart es tener SureCart instalado.