diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index cd3fd0d..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -# Matches .ruby-version -ARG RUBY_VERSION=3.4.7 -FROM ruby:${RUBY_VERSION} - -# postCreateCommand runs "bundle install && rake" -RUN gem install bundler - -ENV BINDING="0.0.0.0" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b091cf6..4385eb9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,12 +1,22 @@ { - "name": "arca", - "build": { - "dockerfile": "Dockerfile", - "context": "..", - "args": { "RUBY_VERSION": "3.4.7" } - }, - "features": { - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - "postCreateCommand": "bundle install && rake" + "name": "arca.rb", + "runArgs": [ + "--name", + "arca.rb" + ], + "image": "ghcr.io/rails/devcontainer/images/ruby:4.0.0", + "workspaceFolder": "/workspaces/arca.rb", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/arca.rb,type=bind,consistency=cached", + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + }, + "postCreateCommand": "bin/devcontainer", + "customizations": { + "vscode": { + "extensions": [ + "Shopify.ruby-lsp", + "anthropic.claude-code" + ] + } + } } \ No newline at end of file diff --git a/.mise.toml b/.mise.toml index dfdef61..c2cf41a 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,2 +1,5 @@ [settings] -idiomatic_version_file_enable_tools = ["ruby"] \ No newline at end of file +idiomatic_version_file_enable_tools = ["ruby"] + +[tools] +ruby = "4.0.0" diff --git a/.ruby-version b/.ruby-version index 81f1b89..0c89fc9 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 \ No newline at end of file +4.0.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 24de3e5..7075671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,19 @@ # Arca Changelog +## [1.1.2] 2025-03-02 + +- **VEConsumer** — Nuevo servicio: Ventanilla Electrónica - Consumir Comunicaciones (`veconsumerws`). Permite consultar y leer comunicaciones enviadas a un contribuyente vía SOAP 1.2. + - `consultar_comunicaciones(filter = {})` — consulta paginada con filtros opcionales (estado, fechas, sistema publicador, referencias, etc.). + - `consumir_comunicacion(id, incluir_adjuntos: false)` — recupera una comunicación y la marca como leída; con `incluir_adjuntos: true` extrae el contenido binario vía MTOM en `adjunto[:content]`. + - `consultar_sistemas_publicadores(id_sistema_publicador: nil)` — lista sistemas publicadores habilitados. + - `consultar_estados` — lista los posibles estados (1=No leída, 2=Leída). + - Los errores SOAP Fault del servicio se traducen a `ResponseError` con soporte para `e.code?`. + ## [1.1.1] - 2025-02-20 -- **WSFE:** Added `tipos_condicion_iva_receptor(clase_cmp: nil)` to query IVA receptor conditions with optional invoice class filter. -- **WSFE:** Added `actividades` to retrieve list of economic activities. -- **WSFE:** Added `consultar_caea_sin_movimientos(caea, pto_vta)` to query CAEA without movements. +- **WSFE:** Agregado `tipos_condicion_iva_receptor(clase_cmp: nil)` para consultar condiciones de IVA del receptor con filtro opcional por clase de comprobante. +- **WSFE:** Agregado `actividades` para obtener el listado de actividades económicas. +- **WSFE:** Agregado `consultar_caea_sin_movimientos(caea, pto_vta)` para consultar CAEA sin movimientos. ## [1.1.0] diff --git a/README.md b/README.md index 37dff3e..dc55c9d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Cliente Ruby para integrar webservices SOAP de AFIP y ARCA en Argentina. Soporta - **Autenticación WSAA** - Manejo automático de tokens y certificados - **Producción y homologación** - Ambientes configurables - **Padrón AFIP** - Consulta de contribuyentes (A4, A5, A100) +- **Ventanilla Electrónica (VEConsumer)** - Consulta y lectura de comunicaciones de AFIP ## Requisitos @@ -72,6 +73,7 @@ Opción del constructor: `env: :development` o `env: :production` (symbol o stri | WS Constancia Inscripción | `Arca::WSConstanciaInscripcion` | Constancia de inscripción | | Padrón A4 / A5 / A100 | `Arca::PersonaServiceA4`, `PersonaServiceA5`, `PersonaServiceA100` | Consulta padrón de contribuyentes | | WConsDeclaracion | `Arca::WConsDeclaracion` | Declaraciones aduaneras | +| VEConsumer | `Arca::VEConsumer` | Ventanilla Electrónica — comunicaciones del contribuyente | ## Uso @@ -173,6 +175,33 @@ ws = Arca::WSConstanciaInscripcion.new(env: :development, cuit: '20123456789', k ws.get_persona('20123456789') ``` +### VEConsumer (Ventanilla Electrónica) +```ruby +ws = Arca::VEConsumer.new(env: :development, cuit: '20123456789', key: key, cert: cert) + +# Consultar comunicaciones (paginado, con filtros opcionales) +r = ws.consultar_comunicaciones(estado: 1, pagina: 1) +r[:items] # Array de ComunicacionSimplificada +r[:total_paginas] # total de páginas + +# Leer una comunicación (la marca como leída) +com = ws.consumir_comunicacion(12_061_068) +com[:estado] # "2" (leída) +com[:adjuntos] # [] si no tiene adjuntos + +# Leer con adjuntos binarios vía MTOM +com = ws.consumir_comunicacion(12_061_068, incluir_adjuntos: true) +com[:adjuntos][0][:filename] # "informe.pdf" +com[:adjuntos][0][:content] # contenido binario (String) + +# Sistemas publicadores habilitados +ws.consultar_sistemas_publicadores +ws.consultar_sistemas_publicadores(id_sistema_publicador: 88) + +# Estados posibles (1=No leída, 2=Leída) +ws.consultar_estados +``` + ### WConsDeclaracion (Declaraciones aduaneras) ```ruby ws = Arca::WConsDeclaracion.new(cuit: '20123456789', key: key, cert: cert, env: :development) diff --git a/bin/devcontainer b/bin/devcontainer new file mode 100755 index 0000000..c03c61f --- /dev/null +++ b/bin/devcontainer @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive + +# Add Charm apt repo for gum and gitleaks +sudo mkdir -p /etc/apt/keyrings +curl -fsSL https://repo.charm.sh/apt/gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/charm.gpg > /dev/null +echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list > /dev/null + +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gh \ + gum \ + zlib1g-dev + +sudo rm -rf /var/lib/apt/lists/* + +if ! command -v mise >/dev/null 2>&1; then + curl -fsSL https://mise.jdx.dev/install.sh | sh + ln -sfn "$HOME/.local/bin/mise" /usr/local/bin/mise + mise trust +fi + +# Claude Code CLI +curl -fsSL https://claude.ai/install.sh | bash \ No newline at end of file diff --git a/lib/arca.rb b/lib/arca.rb index 2afd641..96b0f5a 100644 --- a/lib/arca.rb +++ b/lib/arca.rb @@ -28,4 +28,5 @@ module Arca require "arca/w_cons_declaracion" require "arca/wsrgiva" require "arca/wscdc" +require "arca/ve_consumer" require "arca/wsfecred" diff --git a/lib/arca/ve_consumer.rb b/lib/arca/ve_consumer.rb new file mode 100644 index 0000000..b019c30 --- /dev/null +++ b/lib/arca/ve_consumer.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +# VEConsumer: Web Service de Ventanilla Electrónica - Consumir Comunicaciones (AFIP). +# Permite consultar y leer comunicaciones enviadas a un contribuyente. +# Documentación: VE-CU-WS-Consumir-Comunicaciones v1.3.0. +# Id del servicio WSAA: "veconsumerws". +module Arca + class VEConsumer + WSDL = { + development: "https://stable-middleware-tecno-ext.afip.gob.ar/ve-ws/services/veconsumer?wsdl", + production: "https://infraestructura.afip.gob.ar/ve-ws/services/veconsumer?wsdl", + test: "#{Root}/test/fixtures/ve_consumer/ve_consumer.wsdl" + }.freeze + + attr_reader :wsaa, :cuit + + def initialize(options = {}) + @cuit = normalize_cuit(options[:cuit]) + @wsaa = WSAA.new options.merge(service: "veconsumerws") + @client = Client.new Hash(options[:savon]).reverse_merge( + wsdl: WSDL[@wsaa.env], + soap_version: 2, + convert_request_keys_to: :camelcase + ) + end + + # Consulta comunicaciones del contribuyente con filtros opcionales. + # Claves opcionales del filter: estado, fecha_desde, fecha_hasta, + # comunicacion_id_desde, comunicacion_id_hasta, tiene_adjunto, + # sistema_publicador_id, pagina, resultados_por_pagina, + # referencia1, referencia2. + # Retorna un hash con :pagina, :total_paginas, :items_por_pagina, + # :total_items e :items (Array de ComunicacionSimplificada). + def consultar_comunicaciones(filter = {}) + r = raw_request(:consultar_comunicaciones, auth_request.merge(filter: filter)) + paginada = r[:respuesta_paginada] + paginada.merge(items: get_array(paginada[:items], :comunicacion_simplificada)) + end + + # Recupera una comunicación por id y la marca como leída. + # Con incluir_adjuntos: true se incluyen los adjuntos vía MTOM en la respuesta; + # el contenido binario de cada adjunto queda en :content como String. + # Retorna la Comunicacion con :adjuntos como Array. + def consumir_comunicacion(id_comunicacion, incluir_adjuntos: false) + response = client_request(:consumir_comunicacion, auth_request.merge( + id_comunicacion: id_comunicacion, + incluir_adjuntos: incluir_adjuntos + )) + resp = response.to_hash[:consumir_comunicacion_response] + raise ServerError, "Unexpected response structure" unless resp + + comunicacion = resp[:comunicacion] + comunicacion.merge(adjuntos: build_adjuntos(comunicacion[:adjuntos], response.attachments)) + end + + # Lista los sistemas publicadores habilitados en Ventanilla Electrónica. + # Con id_sistema_publicador filtra por sistema específico. + def consultar_sistemas_publicadores(id_sistema_publicador: nil) + params = auth_request + params = params.merge(id_sistema_publicador: id_sistema_publicador) if id_sistema_publicador + r = raw_request(:consultar_sistemas_publicadores, params) + Array.wrap(r.dig(:sistemas, :sistema)) + end + + # Lista los posibles estados de una comunicación. 1=No leída, 2=Leída. + def consultar_estados + r = raw_request(:consultar_estados, auth_request) + Array.wrap(r.dig(:estados, :estado)) + end + + private + + def auth_request + { auth_request: @wsaa.auth.merge(cuit_representada: cuit) } + end + + def client_request(action, body) + @client.request(action, body) + rescue ServerError => e + raise parse_fault(e) + end + + def raw_request(action, body) + resp = client_request(action, body).to_hash[:"#{action}_response"] + raise ServerError, "Unexpected response structure" unless resp + + resp + end + + def parse_fault(error) + match = error.message.match(/Error (\d+): (.+)/) + return error unless match + + ResponseError.new([ { code: match[1], msg: match[2].strip } ]) + end + + def build_adjuntos(adjuntos_element, mtom_parts) + return [] unless adjuntos_element && adjuntos_element[:adjunto] + + items = Array.wrap(adjuntos_element[:adjunto]) + return items if mtom_parts.empty? + + by_cid = mtom_parts.each_with_object({}) do |part, h| + cid = part.header[:content_id].to_s.tr("<>", "") + h[cid] = part.body.decoded + end + + items.map do |adj| + href = adj.dig(:content, :include, :"@href").to_s + next adj unless href.start_with?("cid:") + + cid = href.delete_prefix("cid:") + adj.merge(content: by_cid.fetch(cid, adj[:content])) + end + end + + def get_array(container, element) + return [] unless container && container[element] + + Array.wrap(container[element]) + end + + def normalize_cuit(value) + if value.nil? || value == "" + 0 + else + value.to_s.gsub(/\D/, "").to_i + end + end + end +end diff --git a/test/arca/ve_consumer_test.rb b/test/arca/ve_consumer_test.rb new file mode 100644 index 0000000..8fe53e6 --- /dev/null +++ b/test/arca/ve_consumer_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "test_helper" + +module Arca + class VEConsumerTest < TestCase + def ta + @ta ||= { token: "t", sign: "s" } + end + + def ws + @ws ||= VEConsumer.new(cuit: "20111111112").tap { |w| w.wsaa.stubs auth: ta } + end + + def test_consultar_comunicaciones + savon.expects(:consultar_comunicaciones) + .with(message: { auth_request: ta.merge(cuit_representada: 20_111_111_112), filter: {} }) + .returns(fixture("ve_consumer/consultar_comunicaciones/success")) + r = ws.consultar_comunicaciones + assert_equal "1", r[:pagina] + assert_equal "1", r[:total_paginas] + assert_equal 1, r[:items].size + assert_hash_includes r[:items][0], id_comunicacion: "1", estado_desc: "Comunicacion Leida" + end + + def test_consultar_comunicaciones_con_filtros + savon.expects(:consultar_comunicaciones) + .with(message: { + auth_request: ta.merge(cuit_representada: 20_111_111_112), + filter: { estado: 1, fecha_desde: "2012-01-01", pagina: 2 } + }) + .returns(fixture("ve_consumer/consultar_comunicaciones/success")) + ws.consultar_comunicaciones(estado: 1, fecha_desde: "2012-01-01", pagina: 2) + end + + def test_consumir_comunicacion + savon.expects(:consumir_comunicacion) + .with(message: { + auth_request: ta.merge(cuit_representada: 20_111_111_112), + id_comunicacion: 12_061_068, + incluir_adjuntos: false + }) + .returns(fixture("ve_consumer/consumir_comunicacion/success")) + r = ws.consumir_comunicacion(12_061_068) + assert_hash_includes r, id_comunicacion: "12061068", estado: "1" + assert_equal [], r[:adjuntos] + end + + def test_consumir_comunicacion_con_adjuntos + boundary = "testboundary" + xml_part = fixture("ve_consumer/consumir_comunicacion/with_adjuntos") + binary = "PKbinarydata" + multipart_body = "--#{boundary}\r\nContent-Type: application/xop+xml; charset=UTF-8\r\n\r\n" \ + "#{xml_part}\r\n" \ + "--#{boundary}\r\nContent-Type: application/octet-stream\r\nContent-ID: \r\n\r\n" \ + "#{binary}\r\n" \ + "--#{boundary}--\r\n" + savon.expects(:consumir_comunicacion) + .with(message: { + auth_request: ta.merge(cuit_representada: 20_111_111_112), + id_comunicacion: 12_061_068, + incluir_adjuntos: true + }) + .returns(code: 200, + headers: { "Content-Type" => "multipart/related; boundary=\"#{boundary}\"" }, + body: multipart_body) + r = ws.consumir_comunicacion(12_061_068, incluir_adjuntos: true) + assert_equal 1, r[:adjuntos].size + assert_equal "attach.zip", r[:adjuntos][0][:filename] + assert_equal binary, r[:adjuntos][0][:content] + end + + def test_soap_fault_levanta_response_error + savon.expects(:consultar_comunicaciones) + .with(message: :any) + .returns(fixture("ve_consumer/consultar_comunicaciones/soap_fault")) + error = assert_raises(ResponseError) { ws.consultar_comunicaciones } + assert error.code?(104) + assert_equal "104", error.errors[0][:code] + assert_equal "La Comunicación [1] no existe", error.errors[0][:msg] + end + + def test_consultar_sistemas_publicadores + savon.expects(:consultar_sistemas_publicadores) + .with(message: { auth_request: ta.merge(cuit_representada: 20_111_111_112) }) + .returns(fixture("ve_consumer/consultar_sistemas_publicadores/success")) + r = ws.consultar_sistemas_publicadores + assert_equal 1, r.size + assert_hash_includes r[0], id: "88", descripcion: "MDQ" + end + + def test_consultar_sistemas_publicadores_con_id + savon.expects(:consultar_sistemas_publicadores) + .with(message: { + auth_request: ta.merge(cuit_representada: 20_111_111_112), + id_sistema_publicador: 88 + }) + .returns(fixture("ve_consumer/consultar_sistemas_publicadores/success")) + ws.consultar_sistemas_publicadores(id_sistema_publicador: 88) + end + + def test_consultar_estados + savon.expects(:consultar_estados) + .with(message: { auth_request: ta.merge(cuit_representada: 20_111_111_112) }) + .returns(fixture("ve_consumer/consultar_estados/success")) + r = ws.consultar_estados + assert_equal 2, r.size + assert_hash_includes r[0], id: "1", descripcion: "Comunicacion No Leida" + assert_equal "2", r[1][:id] + end + + def test_entorno_development + Client.expects(:new).with { |opts| opts[:wsdl] == WSAA::WSDL[:development] }.returns(stub(operations: [])) + Client.expects(:new).with do |opts| + opts[:wsdl] == VEConsumer::WSDL[:development] && opts[:soap_version] == 2 + end.returns(stub(operations: [])) + VEConsumer.new(cuit: "1", env: :development) + end + + def test_entorno_production + Client.expects(:new).with { |opts| opts[:wsdl] == WSAA::WSDL[:production] }.returns(stub(operations: [])) + Client.expects(:new).with do |opts| + opts[:wsdl] == VEConsumer::WSDL[:production] && opts[:soap_version] == 2 + end.returns(stub(operations: [])) + VEConsumer.new(cuit: "1", env: :production) + end + end +end diff --git a/test/fixtures/ve_consumer/consultar_comunicaciones/soap_fault.xml b/test/fixtures/ve_consumer/consultar_comunicaciones/soap_fault.xml new file mode 100644 index 0000000..65f667f --- /dev/null +++ b/test/fixtures/ve_consumer/consultar_comunicaciones/soap_fault.xml @@ -0,0 +1,13 @@ + + + + + + soap:Receiver + + + Error 104: La Comunicación [1] no existe + + + + diff --git a/test/fixtures/ve_consumer/consultar_comunicaciones/success.xml b/test/fixtures/ve_consumer/consultar_comunicaciones/success.xml new file mode 100644 index 0000000..2db5b52 --- /dev/null +++ b/test/fixtures/ve_consumer/consultar_comunicaciones/success.xml @@ -0,0 +1,28 @@ + + + + + + 1 + 1 + 10 + 1 + + + 1 + 20111111112 + 2012-03-01 00:00:00 + 2012-03-01 + 19 + Osiris + 2 + Comunicacion Leida + Usted tiene un archivo adjunto + 3 + true + + + + + + diff --git a/test/fixtures/ve_consumer/consultar_estados/success.xml b/test/fixtures/ve_consumer/consultar_estados/success.xml new file mode 100644 index 0000000..37b4570 --- /dev/null +++ b/test/fixtures/ve_consumer/consultar_estados/success.xml @@ -0,0 +1,17 @@ + + + + + + + 1 + Comunicacion No Leida + + + 2 + Comunicacion Leida + + + + + diff --git a/test/fixtures/ve_consumer/consultar_sistemas_publicadores/success.xml b/test/fixtures/ve_consumer/consultar_sistemas_publicadores/success.xml new file mode 100644 index 0000000..3e939f3 --- /dev/null +++ b/test/fixtures/ve_consumer/consultar_sistemas_publicadores/success.xml @@ -0,0 +1,15 @@ + + + + + + + 88 + MDQ + mdqCN + + + + + + diff --git a/test/fixtures/ve_consumer/consumir_comunicacion/success.xml b/test/fixtures/ve_consumer/consumir_comunicacion/success.xml new file mode 100644 index 0000000..382287e --- /dev/null +++ b/test/fixtures/ve_consumer/consumir_comunicacion/success.xml @@ -0,0 +1,19 @@ + + + + + + 12061068 + 20111111112 + 2011-04-18 13:06:00 + 1 + Sistema Ventanilla Electronica + 1 + Comunicacion No Leida + Actualizacion de Certificado + 2 + + + + + diff --git a/test/fixtures/ve_consumer/consumir_comunicacion/with_adjuntos.xml b/test/fixtures/ve_consumer/consumir_comunicacion/with_adjuntos.xml new file mode 100644 index 0000000..9511763 --- /dev/null +++ b/test/fixtures/ve_consumer/consumir_comunicacion/with_adjuntos.xml @@ -0,0 +1,34 @@ + + + + + + 12061068 + 20111111112 + 2011-07-12 12:21:39 + 2011-07-12 + 88 + MDQ + 1 + Comunicacion No Leida + Mensaje generado por VeClient + 1 + + + attach.zip + false + false + false + false + false + 2ea67624b8cc4340a2a6d4821627412d + 453 + + + + + + + + + diff --git a/test/fixtures/ve_consumer/ve_consumer.wsdl b/test/fixtures/ve_consumer/ve_consumer.wsdl new file mode 100644 index 0000000..e9719cb --- /dev/null +++ b/test/fixtures/ve_consumer/ve_consumer.wsdl @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +