/* Plugin Name: AEX for WooCommerce Description: Integración completa con la API de AEX (v1.5.4): cálculo de flete, solicitud/confirmación de servicio, impresión de guía, tracking, webhooks e inventario. Compatible con checkout clásico y Blocks. Version: 1.0.0 Author: Khaldun + GPT-5 Thinking Requires at least: 6.0 Tested up to: 6.6 WC requires at least: 7.6 WC tested up to: 9.2 License: GPLv2 or later Text Domain: aex-woocommerce */ if ( ! defined( 'ABSPATH' ) ) exit; // ---------- CONSTANTES ---------- const AEX_WC_VERSION = '1.0.0'; const AEX_WC_SLUG = 'aex-woocommerce'; const AEX_WC_NS = 'AEX_WC'; const AEX_WC_DIR = __DIR__; const AEX_WC_URL = __FILE__ ? plugin_dir_url( __FILE__ ) : ''; // Autocarga simple por namespaces de este archivo único (cada "archivo" estará embebido en cadenas) // Para facilitar mantenimiento, todo el código está aquí con separadores. Puedes dividirlo en archivos reales si prefieres. // ============================================================= // helpers.php // ============================================================= if ( ! function_exists( 'aex_wc_log' ) ) { function aex_wc_log( $msg, $context = [] ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { $logger = wc_get_logger(); $logger->info( is_string( $msg ) ? $msg : wp_json_encode( $msg ), ['source' => 'aex-woocommerce', 'context' => $context] ); } } } if ( ! function_exists( 'aex_wc_array_get' ) ) { function aex_wc_array_get( $arr, $key, $default = null ) { return isset( $arr[ $key ] ) ? $arr[ $key ] : $default; } } // ============================================================= // class ApiClient // ============================================================= class AEX_WC_ApiClient { private $public_key; private $private_key; private $base_url; public function __construct( $public_key, $private_key, $env = 'prod' ) { $this->public_key = trim( (string) $public_key ); $this->private_key = trim( (string) $private_key ); $this->base_url = ( $env === 'sandbox' ) ? 'https://sandbox.aex.com.py/api/v1' : 'https://aex.com.py/api/v1'; } private function get_token_transient_key() { return 'aex_wc_token_' . md5( $this->public_key . '|' . $this->base_url ); } private function generate_session_code( $length = 16 ) { $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; $code = ''; for ( $i = 0; $i < $length; $i++ ) { $code .= $chars[ random_int(0, strlen($chars)-1) ]; } return $code; } public function get_token( $force_refresh = false ) { $key = $this->get_token_transient_key(); if ( ! $force_refresh ) { $cached = get_transient( $key ); if ( $cached ) { return $cached; } } $codigo_sesion = $this->generate_session_code(); $payload = [ 'clave_publica' => $this->public_key, 'clave_privada' => md5( $this->private_key . $codigo_sesion ), 'codigo_sesion' => $codigo_sesion, ]; $res = $this->post_json( '/autorizacion-acceso/generar', $payload, false ); if ( is_wp_error( $res ) ) return $res; $code = (int) aex_wc_array_get( $res, 'codigo', -1 ); if ( $code !== 0 ) { return new WP_Error( 'aex_token_error', 'No se pudo obtener token: ' . aex_wc_array_get( $res, 'mensaje', 'Error desconocido' ), $res ); } $token = aex_wc_array_get( $res, 'codigo_autorizacion' ); if ( ! $token ) return new WP_Error( 'aex_token_empty', 'Token vacío' ); // El token dura 10 minutos; cachear 8m para holgura set_transient( $key, $token, 8 * MINUTE_IN_SECONDS ); return $token; } private function build_headers( $needs_token = true ) { $headers = [ 'Content-Type' => 'application/json' ]; if ( $needs_token ) { $token = $this->get_token(); if ( is_wp_error( $token ) ) return $token; // La API usa el token en body, no como header, pero guardamos por si se requiere. } return $headers; } private function post_json( $path, $body, $auto_token = true, $is_file = false ) { $url = rtrim( $this->base_url, '/' ) . $path; $headers = $this->build_headers( $auto_token ); if ( is_wp_error( $headers ) ) return $headers; $args = [ 'headers' => $headers, 'body' => wp_json_encode( $body ), 'timeout' => 30, ]; $response = wp_remote_post( $url, $args ); if ( is_wp_error( $response ) ) return $response; $code = wp_remote_retrieve_response_code( $response ); $ct = wp_remote_retrieve_header( $response, 'content-type' ); $raw = wp_remote_retrieve_body( $response ); if ( $is_file && $code === 200 && strpos( $ct, 'application/pdf' ) !== false ) { return [ 'pdf' => $raw ]; } $data = json_decode( $raw, true ); if ( $data === null ) { return new WP_Error( 'aex_json', 'Respuesta no JSON de AEX', [ 'code' => $code, 'body' => $raw ] ); } return $data; } private function add_auth( $payload ) { $token = $this->get_token(); if ( is_wp_error( $token ) ) return $token; $payload['clave_publica'] = $this->public_key; $payload['codigo_autorizacion'] = $token; return $payload; } public function ciudades( $origen = null ) { $payload = $this->add_auth( [] ); if ( is_wp_error( $payload ) ) return $payload; if ( $origen ) $payload['origen'] = $origen; return $this->post_json( '/envios/ciudades', $payload ); } public function puntos_entrega( $id_tipo_servicio, $origen, $destino ) { $payload = $this->add_auth( compact('id_tipo_servicio','origen','destino') ); if ( is_wp_error( $payload ) ) return $payload; return $this->post_json( '/envios/puntos_entrega', $payload ); } public function imprimir( $guia, $formato = 'guia' ) { $payload = $this->add_auth( [ 'guia' => $guia, 'formato' => $formato ] ); if ( is_wp_error( $payload ) ) return $payload; return $this->post_json( '/envios/imprimir', $payload, true, true ); } public function calcular( $origen, $destino, $paquetes, $codigo_tipo_carga = 'P' ) { $payload = $this->add_auth( [ 'origen' => $origen, 'destino' => $destino, 'paquetes' => $paquetes, 'codigo_tipo_carga' => $codigo_tipo_carga ] ); if ( is_wp_error( $payload ) ) return $payload; return $this->post_json( '/envios/calcular', $payload ); } public function solicitar_servicio( $origen, $destino, $codigo_operacion, $paquetes, $codigo_tipo_carga = 'P', $importe_cobro = 0 ) { $payload = $this->add_auth( compact('origen','destino','codigo_operacion','paquetes','codigo_tipo_carga','importe_cobro') ); if ( is_wp_error( $payload ) ) return $payload; return $this->post_json( '/envios/solicitar_servicio', $payload ); } public function confirmar_servicio( $id_solicitud, $id_tipo_servicio, $remitente, $pickup, $destinatario, $entrega, $adicionales = [], $codigo_forma_pago = 'C', $total_cobro = null ) { $payload = $this->add_auth( [ 'Id_solicitud' => (int) $id_solicitud, 'id_tipo_servicio' => (int) $id_tipo_servicio, 'remitente' => $remitente, 'pickup' => $pickup, 'destinatario' => $destinatario, 'entrega' => $entrega, 'adicionales' => array_values( array_map( 'intval', (array) $adicionales ) ), 'codigo_forma_pago'=> $codigo_forma_pago, ] ); if ( ! is_null( $total_cobro ) ) { $payload['total_cobro'] = (int) $total_cobro; } if ( is_wp_error( $payload ) ) return $payload; return $this->post_json( '/envios/confirmar_servicio', $payload ); } public function cancelar( $numero_guia ) { $payload = $this->add_auth( [ 'numero_guia' => $numero_guia ] ); if ( is_wp_error( $payload ) ) return $payload; return $this->post_json( '/envios/cancelar', $payload ); } public function tracking( $numero_guia = null, $codigo_operacion = null ) { $payload = $this->add_auth( [] ); if ( is_wp_error( $payload ) ) return $payload; if ( $numero_guia ) $payload['numero_guia'] = $numero_guia; if ( $codigo_operacion ) $payload['codigo_operacion'] = $codigo_operacion; return $this->post_json( '/envios/tracking', $payload ); } public function inventario_existencia( $codigos_producto = [] ) { $payload = $this->add_auth( [] ); if ( is_wp_error( $payload ) ) return $payload; if ( ! empty( $codigos_producto ) ) $payload['codigos_producto'] = array_values( (array) $codigos_producto ); return $this->post_json( '/inventario/existencia', $payload ); } } // ============================================================= // class AdminSettings // ============================================================= class AEX_WC_AdminSettings { public static function init() { add_action( 'admin_init', [ __CLASS__, 'register' ] ); add_action( 'admin_menu', [ __CLASS__, 'menu' ] ); } public static function register() { register_setting( 'aex_wc_settings', 'aex_wc_settings', [ __CLASS__, 'sanitize' ] ); add_settings_section( 'aex_wc_main', __( 'Credenciales y Configuración', 'aex-woocommerce' ), '__return_false', 'aex_wc' ); $fields = [ 'env' => [ 'label' => 'Entorno', 'type' => 'select', 'options' => [ 'prod' => 'Producción', 'sandbox' => 'Sandbox' ] ], 'public_key' => [ 'label' => 'Clave pública', 'type' => 'text' ], 'private_key'=> [ 'label' => 'Clave privada', 'type' => 'password' ], 'origen_ciudad'=> [ 'label' => 'Código ciudad de ORIGEN', 'type' => 'text' ], 'map_ciudad_strict'=> [ 'label' => 'Match estricto ciudad destino (por nombre)', 'type' => 'checkbox' ], 'default_forma_pago'=> [ 'label' => 'Forma de pago AEX (C crédito / D destino)', 'type' => 'text', 'placeholder' => 'C' ], 'auto_confirm_status'=> [ 'label' => 'Confirmar servicio al cambiar a este estado de pedido', 'type' => 'text', 'placeholder' => 'processing' ], 'webhook_secret' => [ 'label' => 'Secreto Webhook (validación opcional)', 'type' => 'text' ], ]; foreach ( $fields as $key => $cfg ) { add_settings_field( $key, esc_html( $cfg['label'] ), [ __CLASS__, 'render_field' ], 'aex_wc', 'aex_wc_main', [ 'key' => $key, 'cfg' => $cfg ] ); } } public static function sanitize( $input ) { $clean = []; $clean['env'] = in_array( aex_wc_array_get($input,'env','prod'), ['prod','sandbox'], true ) ? $input['env'] : 'prod'; $clean['public_key'] = sanitize_text_field( aex_wc_array_get($input,'public_key','') ); $clean['private_key']= sanitize_text_field( aex_wc_array_get($input,'private_key','') ); $clean['origen_ciudad'] = sanitize_text_field( aex_wc_array_get($input,'origen_ciudad','') ); $clean['map_ciudad_strict'] = ! empty( $input['map_ciudad_strict'] ) ? 1 : 0; $clean['default_forma_pago'] = strtoupper( sanitize_text_field( aex_wc_array_get($input,'default_forma_pago','C') ) ) === 'D' ? 'D' : 'C'; $clean['auto_confirm_status'] = sanitize_text_field( aex_wc_array_get($input,'auto_confirm_status','') ); $clean['webhook_secret'] = sanitize_text_field( aex_wc_array_get($input,'webhook_secret','') ); return $clean; } public static function render_field( $args ) { $opts = get_option( 'aex_wc_settings', [] ); $key = $args['key']; $cfg = $args['cfg']; $val = aex_wc_array_get( $opts, $key ); if ( $cfg['type'] === 'select' ) { echo ''; } elseif ( $cfg['type'] === 'checkbox' ) { echo ''; } else { printf('', esc_attr($cfg['type']), esc_attr($key), esc_attr($val), esc_attr( aex_wc_array_get($cfg,'placeholder','') ) ); } } public static function menu() { add_options_page( 'AEX', 'AEX', 'manage_woocommerce', 'aex_wc', [ __CLASS__, 'page' ] ); } public static function page() { echo '
Endpoints Webhook: ' . esc_html( site_url( '?wc-api=aex_webhook' ) ) . '
Guía: ' . ( $guia ? esc_html( $guia ) : 'No generada' ) . '
'; $nonce = wp_create_nonce( 'aex_wc_nonce' ); echo ''; if ( $guia ) { echo ''; self::render_tracking_table( $order ); } } private static function client() { $opts = get_option( 'aex_wc_settings', [] ); return new AEX_WC_ApiClient( aex_wc_array_get($opts,'public_key'), aex_wc_array_get($opts,'private_key'), aex_wc_array_get($opts,'env','prod') ); } private static function map_city_code( $name ) { $opts = get_option( 'aex_wc_settings', [] ); $client = self::client(); $res = $client->ciudades(); if ( is_wp_error($res) ) return ''; foreach ( aex_wc_array_get($res,'datos',[]) as $row ) { if ( mb_strtolower( $row['denominacion'] ) === mb_strtolower( $name ) ) return $row['codigo_ciudad']; } if ( empty( $opts['map_ciudad_strict'] ) ) { foreach ( aex_wc_array_get($res,'datos',[]) as $row ) { $n = mb_strtolower( $row['denominacion'] ); if ( str_starts_with( $n, mb_strtolower($name) ) || str_contains( $n, mb_strtolower($name) ) ) return $row['codigo_ciudad']; } } return ''; } private static function build_party_from_order( WC_Order $order, $type = 'destinatario' ) { $first = $type === 'destinatario' ? $order->get_shipping_first_name() : get_bloginfo('name'); $last = $type === 'destinatario' ? $order->get_shipping_last_name() : ''; $email = $type === 'destinatario' ? $order->get_billing_email() : get_option('admin_email'); $phone = preg_replace('/\D+/','', $type === 'destinatario' ? $order->get_billing_phone() : get_option('admin_phone','') ); $city = $type === 'destinatario' ? $order->get_shipping_city() : ''; $city_code = $city ? self::map_city_code( $city ) : ''; $code = (string) ( $type === 'destinatario' ? ( $order->get_customer_id() ?: ( 'GUEST-' . $order->get_id() ) ) : 'STORE-1' ); $tel_arr = $phone ? [['numero' => (int) $phone, 'denominacion' => 'Principal']] : [['numero' => 595000000, 'denominacion' => 'Default']]; return [ 'codigo' => $code, 'tipo_documento' => 'CIP', 'numero_documento' => $code, 'nombre' => $first ?: 'Cliente', 'apellido' => $last, 'email' => $email ?: 'no-reply@example.com', 'personería' => 'F', 'telefonos' => $tel_arr, 'fecha_nacimiento' => '1990-01-01', // direccion/entrega se arma en pickup/entrega ]; } private static function build_location_from_order( WC_Order $order, $for = 'entrega' ) { $street = $order->get_shipping_address_1(); $number = 0; $cross1 = $order->get_shipping_address_2(); $city = $order->get_shipping_city(); $code = strtoupper( substr( md5( $order->get_id() . $for ), 0, 10 ) ); $city_code = $city ? self::map_city_code( $city ) : ''; return [ 'codigo' => $code, 'calle_principal'=> $street ?: 'S/N', 'numero_casa' => (int) $number, 'calle_transversal_1' => $cross1 ?: '-', 'calle_transversal_2' => '-', 'codigo_ciudad' => $city_code ?: 'ASU', 'telefono' => (int) preg_replace('/\D+/','', $order->get_billing_phone() ?: '000' ), 'telefono_movil' => (int) preg_replace('/\D+/','', $order->get_billing_phone() ?: '000' ), 'referencias' => 'Pedido #' . $order->get_order_number(), ]; } public static function maybe_auto_confirm( $order_id, $old, $new, $order ) { $opts = get_option( 'aex_wc_settings', [] ); if ( $new !== aex_wc_array_get( $opts, 'auto_confirm_status', '' ) ) return; self::create_and_confirm( $order ); } public static function handle_create_shipment() { if ( ! current_user_can( 'manage_woocommerce' ) || ! wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'aex_wc_nonce' ) ) wp_die('No permitido'); $order = wc_get_order( absint( $_GET['order_id'] ?? 0 ) ); self::create_and_confirm( $order, true ); wp_safe_redirect( wp_get_referer() ); exit; } private static function create_and_confirm( WC_Order $order = null, $add_note = false ) { if ( ! $order ) return; if ( $order->get_meta('_aex_guia') ) { if ( $add_note ) $order->add_order_note('AEX: ya tenía guía.'); return; } $opts = get_option( 'aex_wc_settings', [] ); $client = self::client(); $origen = aex_wc_array_get( $opts, 'origen_ciudad' ); $destino= self::map_city_code( $order->get_shipping_city() ); $codigo_operacion = (string) $order->get_id(); $paquetes = []; foreach ( $order->get_items() as $item ) { $p = $item->get_product(); if ( ! $p ) continue; $q = (int) $item->get_quantity(); $w = max( 0.01, wc_get_weight( $p->get_weight() ?: 0.5, 'kg' ) ); $L = wc_get_dimension( $p->get_length() ?: 10, 'cm' ); $W = wc_get_dimension( $p->get_width() ?: 10, 'cm' ); $H = wc_get_dimension( $p->get_height() ?: 10, 'cm' ); $paquetes[] = [ 'descripcion' => $p->get_name(), 'codigo_externo' => (string) ( $p->get_sku() ?: $p->get_id() ), 'cantidad' => $q, 'peso' => (float)$w, 'largo' => (float)$L, 'alto' => (float)$H, 'ancho' => (float)$W, 'valor' => (float) $item->get_total() ]; } if ( empty( $paquetes ) ) $paquetes[] = [ 'descripcion' => 'Paquete', 'codigo_externo' => 'GEN', 'cantidad' => 1, 'peso' => 0.5, 'largo' => 10, 'alto' => 10, 'ancho' => 10, 'valor' => (float) $order->get_total() ]; $sol = $client->solicitar_servicio( $origen, $destino, $codigo_operacion, $paquetes ); if ( is_wp_error( $sol ) || (int) aex_wc_array_get($sol,'codigo',-1) !== 0 ) { $order->add_order_note('AEX solicitar_servicio ERROR: ' . ( is_wp_error($sol)?$sol->get_error_message():aex_wc_array_get($sol,'mensaje','') ) ); return; } $id_solicitud = (int) aex_wc_array_get( $sol['datos'] ?? [], 'id_solicitud', 0 ); $condiciones = aex_wc_array_get( $sol['datos'] ?? [], 'condiciones', [] ); $elegida = null; $min = INF; foreach ( $condiciones as $c ) { $costo = floatval( aex_wc_array_get($c,'costo_flete',INF) ); if ( $costo < $min ) { $min = $costo; $elegida = $c; } } if ( ! $elegida ) { $order->add_order_note('AEX: no se hallaron condiciones'); return; } $remitente = self::build_party_from_order( $order, 'remitente' ); $destinatario = self::build_party_from_order( $order, 'destinatario' ); $pickup = self::build_location_from_order( $order, 'pickup' ); $entrega = self::build_location_from_order( $order, 'entrega' ); $adics = []; $forma = aex_wc_array_get( $opts, 'default_forma_pago', 'C' ); $conf = $client->confirmar_servicio( $id_solicitud, (int) $elegida['id_tipo_servicio'], $remitente, $pickup, $destinatario, $entrega, $adics, $forma ); if ( is_wp_error( $conf ) || (int) aex_wc_array_get($conf,'codigo',-1) !== 0 ) { $order->add_order_note('AEX confirmar_servicio ERROR: ' . ( is_wp_error($conf)?$conf->get_error_message():aex_wc_array_get($conf,'mensaje','') ) ); return; } $guia = aex_wc_array_get( $conf['datos'] ?? [], 'numero_guia' ); if ( $guia ) { $order->update_meta_data( '_aex_guia', $guia ); $order->save(); if ( $add_note ) $order->add_order_note( 'AEX: Guía generada ' . $guia ); } } public static function handle_print_label() { if ( ! current_user_can( 'manage_woocommerce' ) || ! wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'aex_wc_nonce' ) ) wp_die('No permitido'); $order = wc_get_order( absint( $_GET['order_id'] ?? 0 ) ); $guia = $order ? $order->get_meta('_aex_guia') : ''; if ( ! $guia ) wp_die('No hay guía'); $client = self::client(); $res = $client->imprimir( $guia, 'guia_A4' ); if ( is_wp_error( $res ) || empty( $res['pdf'] ) ) wp_die('Error al recuperar PDF'); header('Content-Type: application/pdf'); header('Content-Disposition: inline; filename="AEX-'.$guia.'.pdf"'); echo $res['pdf']; exit; } private static function render_tracking_table( WC_Order $order ) { $client = self::client(); $guia = $order->get_meta('_aex_guia'); $res = $client->tracking( $guia, null ); if ( is_wp_error($res) || (int) aex_wc_array_get($res,'codigo',-1) !== 0 ) { echo 'Tracking no disponible.
'; return; } echo 'Fecha | Estado | Tipo |
---|---|---|
%s | %s | %s |