Seguimiento de conversiones de Easy Digital Downloads
Easy Digital Downloads es la alternativa ligera a WooCommerce para ventas puramente digitales: sin envíos, sin inventario, sin la sobrecarga de un motor de impuestos. Beaconry lo trata como un adaptador de comercio de primera clase y mapea casi el mismo embudo que mapea para WooCommerce, del lado del servidor, con una diferencia estructural y un puñado de hooks específicos de EDD. Esto es lo que se dispara dónde y por qué.
El embudo y lo único que falta
EDD obtiene el mismo conjunto de eventos que WooCommerce menos uno. WooCommerce trae diez eventos. EDD trae nueve. El evento que falta es search, y esa es una decisión de alcance deliberada, no un descuido (más sobre esto a continuación). Los nueve eventos de EDD son:
view_item,view_item_list,view_carten la parte superior del embudoadd_to_cart,remove_from_carten el embudo del carritobegin_checkouten la entrada al checkoutpurchaseyrefunden la parte inferior
Cada uno de ellos lleva la misma forma de payload que produce WooCommerce: un array items[] de GA4 (item_id, item_name, item_category, item_brand, price, quantity) Y un bloque de contenido de Meta CAPI (content_ids[], content_type: 'product', content_name, content_category, num_items). Esa paridad es todo el sentido: un catálogo de Dynamic Product Ads en Meta, o un informe de items de GA4, no le importa si la tienda corre WooCommerce o EDD. El payload en el cable es idéntico.
Por qué no hay evento de búsqueda
WooCommerce tiene una plantilla de búsqueda de productos integrada y una ruta de consulta (is_search() más el flag post_type=product) que identifica limpiamente una página de resultados de búsqueda de productos. Beaconry la hookea y dispara search. EDD no tiene una búsqueda de productos de primera clase equivalente. Las descargas se buscan, si acaso, a través de la búsqueda genérica de WordPress, que devuelve un conjunto de resultados mixto (entradas, páginas, descargas) sin una señal nativa de EDD fiable que diga "esto fue una búsqueda de descargas". Disparar search desde la búsqueda genérica de WP significaría adivinar, y adivinar contra la URL o la mezcla de resultados es exactamente el tipo de detección de slugs que Beaconry se niega a hacer. Así que EDD simplemente no emite search. Los otros nueve eventos se resuelven todos a través de APIs reales de EDD, así que se quedan.
view_item: agnóstico a la ruta de render, no atado a un hook de plantilla
El hook obvio para una vista de producto es edd_after_download_content, que se dispara después de que se renderiza el contenido de la descarga individual. Beaconry lo usó una vez y se alejó de él, porque ese hook cuelga del filtro the_content. Las plantillas individuales de los page builders (Elementor Theme Builder, Divi, Bricks) saltan the_content por completo, así que en una página de descarga impulsada por un builder el evento nunca se disparaba.
La solución es hookear template_redirect y limitar con el conditional tag is_singular('download'), que inspecciona la consulta principal (basada en el tipo de entrada) en lugar de la ruta de render. Eso se dispara de forma idéntica para plantillas individuales clásicas, plantillas de bloques (FSE) y plantillas de page builder. El ID de la descarga viene de get_queried_object_id(), así que no hay dependencia de un global $post que un builder podría no establecer.
add_action( 'template_redirect', [ __CLASS__, 'maybe_handle_view_item' ], 20 );
public static function maybe_handle_view_item(): void {
if ( self::is_prefetch() ) {
return; // a speculation-rules prefetch lands before the visitor navigates
}
if ( ! is_singular( 'download' ) ) {
return;
}
$id = (int) get_queried_object_id();
// ... build items[] from edd_get_download( $id ), dispatch view_item
}La misma lógica protege cada evento de vista impulsado por render: un prefetch de speculation rules de Chrome golpea la página antes de que el visitante navegue de verdad, así que un handler sin protección registraría un view_item fantasma. La comprobación de prefetch se retira ante esa cabecera.
view_item_list: tres rutas de render, un evento
Una lista de tienda de EDD puede llegar al navegador de tres maneras distintas, y la misma vista de lista de productos tiene que producir exactamente un view_item_list sin importar qué ruta la renderizó:
- Archivo CPT nativo. La página de tienda
/downloads/por defecto, o un archivo de taxonomíadownload_category/download_tag. Capturado entemplate_redirectvíais_post_type_archive('download')eis_tax(...)en la consulta principal. - El shortcode
[downloads]en una página WP normal. Una página en/products/con el shortcode no tiene una clase de cuerpo de archivo de tipo de entrada, así que los conditional tags de arriba no coinciden. EDD disparaedd_downloads_list_topdurante el render del shortcode con laWP_Queryresuelta de las descargas visibles, que Beaconry hookea directamente. - El bloque de Gutenberg
edd/downloads. La plantilla de render del bloque no tiene ningún action hook en absoluto. Beaconry escucha en el filtro genéricorender_blockde WordPress, y cuando ve ese nombre de bloque vuelve a reproducir la consulta a través de la clase pública\EDD\Downloads\Queryde EDD para obtener los mismos IDs visibles. El propio HTML del bloque se devuelve intacto.
Las tres confluyen en un único dispatcher privado con un guard estático por petición, así que incluso en el caso patológico en que dos rutas se disparen en una página, solo sale el primer view_item_list. La lista está limitada a 20 items por defecto (filtrable vía bcnr_edd_view_item_list_cap) porque la paginación de EDD suele ser de 12 a 24 y no hay valor en enviar un array de 200 items a un matcher de catálogo.
view_cart y begin_checkout: ambos se disparan en la página de checkout
EDD no tiene una página de carrito separada como la tiene WooCommerce por defecto; el carrito se muestra en, y se envía desde, la página de checkout. Así que Beaconry dispara tanto view_cart como begin_checkout cuando se renderiza el checkout. Mismo problema de ruta de render, misma solución: los antiguos hooks de plantilla (edd_after_checkout_cart, edd_checkout_form_top) solo se disparan dentro del render del shortcode clásico [download_checkout]. En el bloque de Checkout de EDD y en los checkouts de page builder permanecen en silencio, y ambos eventos faltaban allí.
El gate agnóstico al render es edd_is_checkout(), que delega en el propio \EDD\Checkout\Validator::is_checkout() de EDD y se resuelve comparando el ID de la página de compra configurada (is_page(edd_get_option('purchase_page'))). Eso es una comparación de ID de página, no una coincidencia de URL, así que es correcto sin importar cómo haya renombrado la tienda su slug de checkout.
add_action( 'template_redirect', [ __CLASS__, 'maybe_handle_checkout_funnel' ], 20 );
public static function maybe_handle_checkout_funnel(): void {
if ( self::is_prefetch() ) {
return;
}
if ( ! edd_is_checkout() ) {
return;
}
self::handle_view_cart(); // GA4 view_cart, current edd_get_cart_contents()
self::handle_begin_checkout(); // GA4 begin_checkout, same cart snapshot
}Ambos handlers llevan su propio guard estático por petición y se retiran ante un carrito vacío, así que una recarga de la página de checkout no dispara dos veces. Ambos leen el carrito en vivo a través de edd_get_cart_contents() y el total a través de edd_get_cart_total(), la API pública de EDD, nunca una estructura interna de sesión.
add_to_cart y remove_from_cart: la trampa de la firma de hooks de EDD
Los hooks de mutación del carrito de EDD han cambiado sus formas de argumentos a lo largo de las versiones, y la documentación no los fija. Beaconry los vincula de forma defensiva y lee los argumentos reales con func_get_args() en lugar de confiar en una lista fija de parámetros.
Un bug concreto que esto atrapó: edd_post_remove_from_cart pasa tres argumentos, no cuatro, y el segundo es el entero download_id, no el array del item del carrito. El código anterior leía el índice de argumento 1 como el array del item, así que una comprobación is_array() lo anulaba silenciosamente en cada llamada y el item eliminado nunca se resolvía. Leer desde la fuente (includes/cart/actions.php: do_action( 'edd_post_remove_from_cart', int $cart_key, int $download_id, array $cart_item )) lo arregló.
Se ha observado que ambos hooks del carrito se disparan varias veces por carga de página (reconstrucciones del carrito en un redirect-after-action, refresco de la UI, sincronización del estado del carrito, reconstrucción del recibo, todos los vuelven a desencadenar). Cada handler mantiene un conjunto de deduplicación estático por petición, así que tres disparos internos colapsan en un evento. La clave de deduplicación para add_to_cart es download_id + md5(serialize(options)), así que una repetición genuina de añadir el mismo producto en una variante distinta sigue registrándose como un evento diferenciado, mientras que un disparo duplicado del mismo add idéntico no lo hace.
Una sutileza que vale la pena señalar: estos son eventos de acción, no eventos de vista. Dos clics reales de "añadir al carrito" deben seguir siendo dos eventos. Así que, a diferencia de los eventos de vista, add y remove nunca obtienen un event_id determinista que colapse, obtienen un UUID nuevo por dispatch. El truco del ID determinista está reservado para las vistas impulsadas por render, donde un prefetch o una recarga representan de verdad una vista lógica.
purchase y refund: IDs deterministas y deduplicación
Purchase se dispara en edd_complete_purchase, que corre cuando un pago pasa al estado completo (publish). El event_id es determinista: bcnr_edd_purchase_<payment_id>. Una recarga de la página de agradecimiento o una entrega repetida del webhook produce el mismo ID, así que las plataformas que deduplican nunca cuentan dos veces, y un marcador de meta de la entrada de pago (_bcnr_purchase_event_fired) da una segunda capa de idempotencia persistente que sobrevive entre peticiones.
Las líneas de pedido para la compra vienen de edd_get_payment_meta_cart_details(), la instantánea histórica del carrito, no el precio en vivo de la descarga. Eso importa: el precio y la cantidad se sobrescriben con lo que realmente se cobró en el momento de la compra, así que una devolución seis semanas más tarde reporta el importe original aunque la descarga se haya vuelto a fijar de precio desde entonces.
Refund observa edd_update_payment_status y filtra a $new_status === 'refunded'. Reutiliza la transaction_id determinista (edd_payment_<payment_id>) y las líneas de pedido históricas para que la devolución se compense limpiamente contra la compra original.
Refund es un evento solo de GA4. Esto es lo más contraintuitivo del seguimiento de comercio, así que vale la pena decirlo claramente: de todos los canales a los que Beaconry reenvía, solo GA4 tiene un evento de devolución real, uno que compensa los ingresos devueltos contra la compra registrada. Cada canal de anuncios se salta en la devolución, porque ninguno de ellos tiene un evento de devolución que haga algo útil. Meta CAPI no tiene ningún tipo de evento de devolución en absoluto (su API de cancelación y devolución es la API de gestión de pedidos de la Commerce Platform, un producto distinto, no la Conversions API). TikTok no lista ninguna devolución en sus eventos soportados. Pinterest, Snapchat y Reddit solo pueden tomar una cadena "refund" personalizada, que es un contador muerto sin valor de Smart Bidding ni de compensación de ingresos, y en Snapchat incluso chocaría con el slot de lead. Así que Beaconry envía refund a GA4 y se salta cada canal de anuncios, de forma consistente. Disparar un evento de devolución personalizado a una plataforma de anuncios no corregiría el ROAS reportado, solo añadiría ruido; compensar los ingresos es algo que solo GA4 hace de verdad.
Hashing de PII: de dónde vienen las claves de coincidencia
Purchase y refund lo tienen fácil: el pago de EDD lleva el email, el nombre, el teléfono y la dirección de facturación del cliente, y Beaconry los lee a través del helper normalizado get_payment() en un mapa user_data (em, fn, ln, ph, ct, st, zp, country). El forwarder aplica un hash SHA-256 a cada campo por canal antes de que salga del servidor.
Los eventos del embudo del carrito son el caso difícil. Un view_item o add_to_cart ocurre antes de que el visitante haya escrito nada en un formulario de checkout, así que de fábrica no llevan identificadores, y Pinterest, Meta y TikTok entonces advierten sobre una calidad de coincidencia pobre. EDD no tiene un equivalente a la caché de facturación WC()->customer de WooCommerce, así que Beaconry extrae la identidad previa al checkout de otras dos fuentes, intercaladas a través de un filtro bcnr_pre_dispatch_event que solo toca eventos del embudo de EDD:
- La sesión de cliente de EDD (
EDD()->session->get('customer')), que rellena email / nombre / dirección una vez que el visitante ha interactuado con el formulario de checkout, invitados incluidos. - La cuenta de usuario WP con sesión iniciada, que llena los huecos cuando un cliente navega por el área de cuenta antes de que la sesión de EDD tenga nada en ella.
Hay un guard deliberado tomado prestado del adaptador de WooCommerce: la función de geolocalización de EDD (usada para impuestos) puede autorrellenar country y state en la sesión antes de que el visitante haya entrado realmente en el formulario de checkout. Así que los campos de dirección (ciudad, estado, código postal, país) solo se adoptan cuando al menos un campo de identidad verdadero (email, nombre o teléfono) está establecido, campos que la geolocalización nunca rellena. Sin esa comprobación, enviarías un "country" hasheado como clave de coincidencia para un visitante que nunca se identificó, lo cual es tanto incorrecto como inútil para la coincidencia.
Una capa más: tras una compra completada, Beaconry persiste las claves de coincidencia en bruto en su bóveda cifrada de PII de origen. La siguiente sesión del embudo del carrito de un cliente que vuelve enriquece entonces sus eventos view_item y add_to_cart con esas claves automáticamente, sin necesidad de reintroducirlas, que es de donde viene la ganancia en tasa de coincidencia con compradores recurrentes.
La regla de detección de slugs, aplicada a EDD
Toda la identificación de páginas y objetos de arriba pasa por una API de EDD o de WordPress, nunca por una cadena de URL. is_singular('download') para la página de producto, edd_is_checkout() para el checkout, is_post_type_archive('download') e is_tax(...) para la tienda, edd_get_download() y get_post_type() === 'download' para el tipo de objeto. Nada coincide con /downloads/ o /checkout/ en una regex. Eso es lo que permite que el adaptador funcione sin cambios en una tienda alemana que renombró su página de checkout, en un sitio multilingüe con bases de taxonomía localizadas, o en una tienda que movió su tienda a /store/. Si una API no puede identificar una página, los datos se aceptan como ausentes en lugar de adivinarse.
Conclusión
EDD obtiene nueve de los diez eventos de WooCommerce, faltando solo search, porque EDD no tiene una búsqueda de productos de primera clase que hookear sin adivinar. Todo lo demás está en plena paridad: payloads idénticos de items[] y de bloque de contenido de Meta, event_ids deterministas en vistas y compras para una deduplicación limpia, líneas de pedido históricas para que las devoluciones se compensen correctamente, y el mismo hooking agnóstico a la ruta de render (template_redirect más conditional tags) que sobrevive a temas clásicos, de bloques y de page builder. Refund va solo a GA4, el único canal que compensa ingresos de verdad, y se salta cada canal de anuncios porque ninguno de ellos tiene un evento de devolución que valga la pena disparar. La única configuración del lado de EDD es "tener EDD instalado".