401, 'codigo' => 'SIN_LLAVE', 'cuerpo' => null, ) ); } $cabeceras['X-API-Key'] = $llave; } $cabeceras['Accept'] = 'application/json'; $args = array( 'method' => $metodo, 'timeout' => isset( $opciones['timeout'] ) ? (int) $opciones['timeout'] : 30, 'redirection' => 0, 'headers' => $cabeceras, 'user-agent' => 'ValidaFirma-WordPress/' . VALIDAFIRMA_VERSION . '; ' . get_bloginfo( 'version' ), ); if ( isset( $opciones['body'] ) ) { $args['body'] = $opciones['body']; } if ( ! empty( $opciones['stream'] ) ) { $args['stream'] = true; $args['filename'] = $opciones['filename']; } $respuesta = wp_remote_request( $this->base() . $ruta, $args ); if ( is_wp_error( $respuesta ) ) { return new WP_Error( 'validafirma_red', __( 'ValidaFirma no respondió.', 'validafirma' ) ); } $status = (int) wp_remote_retrieve_response_code( $respuesta ); $crudo = empty( $opciones['stream'] ) ? (string) wp_remote_retrieve_body( $respuesta ) : ''; $cuerpo = '' !== $crudo ? json_decode( $crudo, true ) : null; $cuerpo = is_array( $cuerpo ) ? $cuerpo : null; if ( $status >= 200 && $status < 300 ) { return array( 'status' => $status, 'cuerpo' => $cuerpo, 'crudo' => $crudo, 'cabeceras' => wp_remote_retrieve_headers( $respuesta ), ); } if ( 401 === $status && 'sin_llave' !== $modo ) { Validafirma_Conexion::marcar_llave_revocada(); } $codigo = ''; if ( is_array( $cuerpo ) ) { if ( isset( $cuerpo['codigo'] ) && is_string( $cuerpo['codigo'] ) ) { $codigo = $cuerpo['codigo']; } elseif ( isset( $cuerpo['error'] ) && is_string( $cuerpo['error'] ) && preg_match( '/^[A-Z_]+$/', $cuerpo['error'] ) ) { $codigo = $cuerpo['error']; } } if ( ! empty( $opciones['stream'] ) && ! empty( $opciones['filename'] ) ) { wp_delete_file( $opciones['filename'] ); } return new WP_Error( 'validafirma_api', self::mensaje_para( $status, $codigo ), array( 'status' => $status, 'codigo' => $codigo, 'cuerpo' => $cuerpo, ) ); } /** * Texto para la persona según el error de la API (nunca trae datos del cuerpo). * * @param int $status Código HTTP. * @param string $codigo Código de error de la API. * @return string */ public static function mensaje_para( $status, $codigo ) { if ( 402 === $status ) { return __( 'El saldo no alcanza para este envío.', 'validafirma' ); } if ( 401 === $status ) { return __( 'ValidaFirma no reconoce la conexión de este sitio. Vuelve a conectar tu cuenta.', 'validafirma' ); } switch ( $codigo ) { case 'MODO_PRUEBA_TOPE_DIARIO': return __( 'Se alcanzó el tope diario de documentos del modo de prueba. Vuelve a intentarlo mañana.', 'validafirma' ); case 'REENVIO_EN_VENTANA': return __( 'La invitación se reenvió hace menos de 5 minutos. Espera un momento y vuelve a intentarlo.', 'validafirma' ); case 'REENVIO_TOPE_ALCANZADO': return __( 'Esa invitación alcanzó su tope de reenvíos: uno por WhatsApp y cinco por correo.', 'validafirma' ); case 'REENVIO_INTENTOS_AGOTADOS': return __( 'Ya se intentó tres veces enviar la invitación a ese destinatario sin éxito. Revisa su correo o su teléfono.', 'validafirma' ); case 'FIRMANTE_BLOQUEADO': return __( 'El firmante está bloqueado y no se le puede reenviar la invitación.', 'validafirma' ); case 'YA_FIRMADO': return __( 'El firmante ya firmó.', 'validafirma' ); case 'WEBHOOK_URL_NO_PERMITIDA': return __( 'La dirección de avisos de este sitio no cumple lo que ValidaFirma exige.', 'validafirma' ); case 'OPERACION_NO_DISPONIBLE_EN_PRUEBA': return __( 'Esa operación no existe en el modo de prueba.', 'validafirma' ); case 'DOCUMENTO_NO_ENCONTRADO': return __( 'ValidaFirma no encontró el documento.', 'validafirma' ); } if ( 400 === $status || 422 === $status ) { return __( 'ValidaFirma no aceptó los datos del envío. Revisa los RUT, los contactos y el PDF.', 'validafirma' ); } if ( $status >= 500 ) { return __( 'ValidaFirma tuvo un problema. Vuelve a intentarlo en unos minutos.', 'validafirma' ); } return __( 'ValidaFirma no pudo completar la operación.', 'validafirma' ); } /** * ¿Es una falla pasajera (conviene reintentar con la misma clave)? * * @param WP_Error $error Error devuelto por solicitar(). * @return bool */ public static function es_pasajero( WP_Error $error ) { if ( 'validafirma_red' === $error->get_error_code() ) { return true; } $datos = $error->get_error_data(); $status = is_array( $datos ) && isset( $datos['status'] ) ? (int) $datos['status'] : 0; $codigo = is_array( $datos ) && isset( $datos['codigo'] ) ? $datos['codigo'] : ''; return $status >= 500 || in_array( $status, array( 408, 425, 429 ), true ) || in_array( $codigo, array( 'IDEMPOTENCIA_EN_CURSO', 'IDEMPOTENCIA_INCONCLUSA' ), true ); } /** * Estado HTTP y código de un error de la API. * * @param WP_Error $error Error. * @return array{status:int, codigo:string, cuerpo:?array} */ public static function datos_de( WP_Error $error ) { $datos = $error->get_error_data(); return array( 'status' => is_array( $datos ) && isset( $datos['status'] ) ? (int) $datos['status'] : 0, 'codigo' => is_array( $datos ) && isset( $datos['codigo'] ) ? (string) $datos['codigo'] : '', 'cuerpo' => is_array( $datos ) && isset( $datos['cuerpo'] ) ? $datos['cuerpo'] : null, ); } /** * Arma un cuerpo multipart con campos de texto y un archivo. * * @param array $campos nombre => valor. * @param string $campo_archivo Nombre del campo del archivo. * @param string $ruta Ruta del archivo. * @param string $nombre_archivo Nombre del archivo que ve la API. * @return array{0:string,1:string}|WP_Error Tipo de contenido y cuerpo. */ public static function multipart( array $campos, $campo_archivo, $ruta, $nombre_archivo ) { $contenido = is_readable( $ruta ) ? file_get_contents( $ruta ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- archivo local. if ( false === $contenido ) { return new WP_Error( 'validafirma_archivo', __( 'No se pudo leer el PDF.', 'validafirma' ) ); } $limite = 'validafirma' . bin2hex( random_bytes( 12 ) ); $cuerpo = ''; foreach ( $campos as $nombre => $valor ) { $cuerpo .= "--{$limite}\r\nContent-Disposition: form-data; name=\"{$nombre}\"\r\n\r\n{$valor}\r\n"; } $nombre_archivo = str_replace( array( '"', "\r", "\n" ), '', $nombre_archivo ); $cuerpo .= "--{$limite}\r\nContent-Disposition: form-data; name=\"{$campo_archivo}\"; filename=\"{$nombre_archivo}\"\r\nContent-Type: application/pdf\r\n\r\n" . $contenido . "\r\n--{$limite}--\r\n"; return array( 'multipart/form-data; boundary=' . $limite, $cuerpo ); } /** * Crea un documento (POST /api/fes/documentos). * * @param string $modo prueba | produccion. * @param string $ruta_pdf Ruta del PDF. * @param string $nombre_archivo Nombre del archivo para la API. * @param array $campos firmantes (texto JSON), dias_expiracion, confidencial, webhook_url, idempotencyKey. * @return array|WP_Error */ public function crear_documento( $modo, $ruta_pdf, $nombre_archivo, array $campos ) { $armado = self::multipart( $campos, 'documento', $ruta_pdf, $nombre_archivo ); if ( is_wp_error( $armado ) ) { return $armado; } return $this->solicitar( 'POST', '/api/fes/documentos', $modo, array( 'headers' => array( 'Content-Type' => $armado[0] ), 'body' => $armado[1], 'timeout' => 60, ) ); } /** * Estado de un documento. * * @param string $modo Modo de la llave. * @param string $documento_id Id del documento. * @return array|WP_Error */ public function estado_documento( $modo, $documento_id ) { return $this->solicitar( 'GET', '/api/fes/documentos/' . rawurlencode( $documento_id ) . '/estado', $modo ); } /** * Descarga el PDF firmado a un archivo temporal. * * @param string $modo Modo de la llave. * @param string $documento_id Id del documento. * @param int $timeout Segundos que puede tardar. * @return string|WP_Error Ruta del archivo temporal. */ public function descargar_documento( $modo, $documento_id, $timeout = 120 ) { // La descarga corre en WP-Cron, donde WordPress no carga las funciones de archivo del administrador. if ( ! function_exists( 'wp_tempnam' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $temporal = wp_tempnam( 'validafirma' ); if ( ! $temporal ) { return new WP_Error( 'validafirma_archivo', __( 'No se pudo crear un archivo temporal.', 'validafirma' ) ); } $respuesta = $this->solicitar( 'GET', '/api/fes/documentos/' . rawurlencode( $documento_id ) . '/descargar', $modo, array( 'stream' => true, 'filename' => $temporal, 'timeout' => (int) $timeout, ) ); if ( is_wp_error( $respuesta ) ) { wp_delete_file( $temporal ); return $respuesta; } $cabecera = file_get_contents( $temporal, false, null, 0, 5 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- archivo local. if ( '%PDF-' !== $cabecera ) { wp_delete_file( $temporal ); return new WP_Error( 'validafirma_api', __( 'ValidaFirma no entregó un PDF.', 'validafirma' ), array( 'status' => 502, 'codigo' => 'NO_ES_PDF', 'cuerpo' => null, ) ); } return $temporal; } /** * Cancela un documento pendiente. * * @param string $modo Modo de la llave. * @param string $documento_id Id del documento. * @return array|WP_Error */ public function cancelar_documento( $modo, $documento_id ) { return $this->solicitar( 'PATCH', '/api/fes/documentos/' . rawurlencode( $documento_id ) . '/cancelar', $modo ); } /** * Reenvía la invitación a los firmantes pendientes. * * @param string $modo Modo de la llave. * @param string $documento_id Id del documento. * @return array|WP_Error */ public function reenviar_invitacion( $modo, $documento_id ) { return $this->solicitar( 'POST', '/api/fes/documentos/' . rawurlencode( $documento_id ) . '/reenviar-notificaciones', $modo ); } /** * Saldo de la cuenta personal. * * @return array|WP_Error */ public function saldo() { return $this->solicitar( 'GET', '/api/creditos/saldo' ); } /** * Mensajes simulados de un documento de prueba. * * @param string $documento_id Id del documento. * @return array|WP_Error */ public function mensajes_simulados( $documento_id ) { return $this->solicitar( 'GET', '/api/fes/mensajes-simulados?documento_id=' . rawurlencode( $documento_id ) . '&limit=50', 'prueba' ); } /** * Acción de simulación sobre un firmante de un documento de prueba (D-25). * * @param string $documento_id Id del documento. * @param string $firmante_id Id del firmante. * @param string $accion firmar | rechazar-documento. * @return array|WP_Error */ public function simular( $documento_id, $firmante_id, $accion ) { if ( ! in_array( $accion, array( 'firmar', 'rechazar-documento' ), true ) ) { return new WP_Error( 'validafirma_api', __( 'Acción de simulación desconocida.', 'validafirma' ), array( 'status' => 400, 'codigo' => 'ACCION_DESCONOCIDA', 'cuerpo' => null, ) ); } return $this->solicitar( 'POST', '/api/fes/documentos/' . rawurlencode( $documento_id ) . '/firmantes/' . rawurlencode( $firmante_id ) . '/simulacion/' . $accion, 'prueba' ); } /** * Vence un documento de prueba sin esperar su plazo. * * @param string $documento_id Id del documento. * @return array|WP_Error */ public function simular_vencimiento( $documento_id ) { return $this->solicitar( 'POST', '/api/fes/documentos/' . rawurlencode( $documento_id ) . '/simulacion/vencer', 'prueba' ); } /** * Poderes de firma (firmantes desatendidos) de la cuenta, del modo pedido: id, nombre y vigencia. * * @param string $modo prueba | produccion. * @return array|WP_Error Lista de {id, nombre, vigente}. */ public function firmantes_desatendidos( $modo ) { $clave = 'validafirma_desat_' . md5( $modo . '|' . Validafirma_Conexion::llave( $modo ) ); $guardado = get_transient( $clave ); if ( is_array( $guardado ) ) { return $guardado; } $respuesta = $this->solicitar( 'GET', '/api/conexiones-de-sitios/actual/firmantes-desatendidos', $modo ); if ( is_wp_error( $respuesta ) ) { return $respuesta; } $cuerpo = is_array( $respuesta['cuerpo'] ) ? $respuesta['cuerpo'] : array(); $lista = isset( $cuerpo['firmantes'] ) && is_array( $cuerpo['firmantes'] ) ? $cuerpo['firmantes'] : $cuerpo; $salida = array(); foreach ( $lista as $item ) { if ( is_array( $item ) && isset( $item['id'] ) ) { $salida[] = array( 'id' => (string) $item['id'], 'nombre' => isset( $item['nombre'] ) ? (string) $item['nombre'] : '', 'vigente' => ! isset( $item['vigente'] ) || (bool) $item['vigente'], ); } } set_transient( $clave, $salida, 2 * MINUTE_IN_SECONDS ); return $salida; } /** * Datos de la conexión del sitio (modo, cuenta, secreto vigente). * * @param string $modo Modo de la llave. * @return array|WP_Error */ public function conexion_actual( $modo ) { return $this->solicitar( 'GET', '/api/conexiones-de-sitios/actual', $modo ); } /** * Revoca las llaves del sitio en ValidaFirma. * * @param string $modo Modo de la llave con que se pide. * @return array|WP_Error */ public function desconectar( $modo ) { return $this->solicitar( 'DELETE', '/api/conexiones-de-sitios/actual', $modo ); } /** * Canjea el código de un solo uso por la llave del sitio (servidor a servidor, PKCE). * * @param string $codigo Código recibido en el regreso. * @param string $verificador code_verifier de PKCE. * @param string $redirect_uri Misma dirección de regreso que se mandó al autorizar. * @param string|null $llave_de_prueba Llave de prueba del sitio, para pasar a producción. * @return array|WP_Error */ public function canjear( $codigo, $verificador, $redirect_uri, $llave_de_prueba = null ) { $cabeceras = array( 'Content-Type' => 'application/json' ); if ( $llave_de_prueba ) { $cabeceras['X-API-Key'] = $llave_de_prueba; } return $this->solicitar( 'POST', '/api/conexiones-de-sitios/canje', 'sin_llave', array( 'headers' => $cabeceras, 'body' => wp_json_encode( array( 'codigo' => $codigo, 'code_verifier' => $verificador, 'redirect_uri' => $redirect_uri, ) ), ) ); } }