snoopy_afip 4.3.0 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +31 -0
- data/.github/workflows/release.yml +33 -0
- data/AGENTS.md +100 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +15 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +68 -19
- data/README.md +87 -189
- data/docs/behavior/behavior.md +84 -0
- data/docs/config/configuracion.md +94 -0
- data/docs/consumed/afip.md +61 -0
- data/docs/errors/errors.md +69 -0
- data/docs/glossary/glossary.md +74 -0
- data/docs/interface/interface.md +86 -0
- data/docs/test/testing.md +74 -0
- data/docs/topology/topology.md +44 -0
- data/lib/snoopy_afip/authorize_adapter.rb +5 -5
- data/lib/snoopy_afip/exceptions.rb +12 -0
- data/lib/snoopy_afip/version.rb +1 -1
- data/lib/snoopy_afip.rb +0 -4
- data/skill/SKILL.md +72 -0
- data/skills.yml +17 -0
- data/snoopy_afip.gemspec +5 -3
- data/spec/snoopy_afip/authentication_adapter_spec.rb +20 -0
- data/spec/snoopy_afip/authorize_adapter_spec.rb +37 -0
- data/spec/snoopy_afip/bill_spec.rb +66 -83
- data/spec/snoopy_afip/exceptions_spec.rb +27 -0
- data/spec/spec_helper.rb +16 -20
- metadata +56 -7
- data/CHANGELOG +0 -27
- data/spec/snoopy_afip/authorizer_spec.rb +0 -9
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Glosario — snoopy_afip
|
|
2
|
+
> meta: artefacto · RFC-009 · generado arch-enrich · anclado a 7813cf2 · cobertura: términos de dominio AFIP materializados en `lib/`; parcial, acreta por PR
|
|
3
|
+
|
|
4
|
+
Bounded context: facturación electrónica AFIP (Argentina). Sin capa de datos (`docs/data/` n/a) → binding a símbolo público estable (ISO 11179), no a tabla.
|
|
5
|
+
|
|
6
|
+
## WSAA
|
|
7
|
+
|
|
8
|
+
Web Service de Autenticación y Autorización de AFIP. Recibe un CMS firmado (cert + clave privada) y devuelve `token` + `sign` válidos 12h (la gema pide `to = from + 12h`). Es el prerrequisito para operar contra WSFE.
|
|
9
|
+
|
|
10
|
+
**Binding:** `Snoopy::AuthenticationAdapter` (`authentication_adapter.rb`); operación `:login_cms`.
|
|
11
|
+
|
|
12
|
+
## WSFE
|
|
13
|
+
|
|
14
|
+
Web Service de Facturación Electrónica v1 de AFIP. Autoriza comprobantes y devuelve el CAE. Requiere las credenciales de WSAA en cada request (`Auth`).
|
|
15
|
+
|
|
16
|
+
**Binding:** `Snoopy::AuthorizeAdapter` (`authorize_adapter.rb`); operaciones `:fecae_solicitar`, `:fe_comp_ultimo_autorizado`, `:fe_comp_consultar`.
|
|
17
|
+
|
|
18
|
+
## CAE (Código de Autorización Electrónico)
|
|
19
|
+
|
|
20
|
+
Código que AFIP otorga al aprobar un comprobante; sin CAE la factura no es válida. Viene con fecha de vencimiento (`cae_fch_vto`).
|
|
21
|
+
|
|
22
|
+
**Binding:** `Bill#cae`, `Bill#due_date_cae`; seteados en `AuthorizeAdapter#parse_fecae_solicitar_response`.
|
|
23
|
+
|
|
24
|
+
## CMS / TRA
|
|
25
|
+
|
|
26
|
+
**TRA** (Ticket de Requerimiento de Acceso): XML `loginTicketRequest` con `uniqueId`, `generationTime`, `expirationTime` y `service=wsfe`. **CMS**: el TRA firmado en PKCS7 (cert + pkey), base64, que se manda a WSAA.
|
|
27
|
+
|
|
28
|
+
**Binding:** `AuthenticationAdapter#build_tra` (TRA), `#build_cms` (CMS).
|
|
29
|
+
|
|
30
|
+
## cbte_type (tipo de comprobante)
|
|
31
|
+
|
|
32
|
+
Código AFIP del tipo de comprobante (Factura A/B/C, Nota de Crédito A/B/C). En la gema se **deriva** de la condición IVA del receptor (`receiver_iva_cond`), no se pasa directo.
|
|
33
|
+
|
|
34
|
+
**Binding:** `Bill#cbte_type` → `Snoopy::BILL_TYPE[receiver_iva_cond]`; tabla completa en `Snoopy::CBTE_TYPE`.
|
|
35
|
+
|
|
36
|
+
## alicivas (alícuotas de IVA)
|
|
37
|
+
|
|
38
|
+
Discriminación de IVA por ítem del comprobante: array de hashes `{id, amount, taxeable_base}` donde `id` es el porcentaje de IVA (0.105, 0.21, 0.27…) mapeado al código AFIP. Monotributistas no las informan.
|
|
39
|
+
|
|
40
|
+
**Binding:** `Bill#alicivas`, `Bill::TAX_ATTRIBUTES`; código AFIP en `Snoopy::ALIC_IVA`; armado en `AuthorizeAdapter#build_body_request` (clave `Iva`).
|
|
41
|
+
|
|
42
|
+
## Condición IVA — emisor vs receptor
|
|
43
|
+
|
|
44
|
+
Tres conceptos distintos conviven:
|
|
45
|
+
- **`issuer_iva_cond`** (emisor): determina si se informan alícuotas (monotributo no). Valores en `Snoopy::IVA_COND`.
|
|
46
|
+
- **`receiver_iva_cond`** (receptor): determina el `cbte_type` (clave de `Snoopy::BILL_TYPE`).
|
|
47
|
+
- **`receiver_iva_condition`** (RG 5616, "verdadera condición IVA del receptor"): id que AFIP exige en el detalle (`CondicionIVAReceptorId`). Valores en `Snoopy::IVA_COND_RECEIVER`.
|
|
48
|
+
|
|
49
|
+
**Binding:** `Bill#issuer_iva_cond`, `#receiver_iva_cond`, `#receiver_iva_condition` / `#receiver_iva_condition_id`.
|
|
50
|
+
|
|
51
|
+
## Resultado (A / P / R)
|
|
52
|
+
|
|
53
|
+
Veredicto de AFIP sobre el comprobante: `A` aprobado, `P` aprobado parcial, `R` rechazado.
|
|
54
|
+
|
|
55
|
+
**Binding:** `Bill#result`, `#approved?`, `#partial_approved?`, `#rejected?`.
|
|
56
|
+
|
|
57
|
+
## afip_errors / afip_events / afip_observations
|
|
58
|
+
|
|
59
|
+
Tres canales que AFIP devuelve en la respuesta, parseados en hashes `code => msg` separados. **Errores**: motivos de rechazo. **Eventos**: avisos de AFIP (ej. cambios futuros). **Observaciones**: motivos por los que no se autorizó. No son excepciones Ruby — el flujo no explota (ver `docs/behavior/`).
|
|
60
|
+
|
|
61
|
+
**Binding:** `AuthorizeAdapter#afip_errors/#afip_events/#afip_observations`.
|
|
62
|
+
|
|
63
|
+
## 3. Inferencias
|
|
64
|
+
|
|
65
|
+
| término | confidence | nota |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `receiver_iva_condition` (RG 5616) | inferred | el comentario del código dice "la verdadera condición de iva del receptor"; confirmar nombre de negocio exacto |
|
|
68
|
+
| Candidato canónico (RFC-019) | inferred | `CAE`, `WSFE`, `condición IVA` probablemente aparecen en otros repos de facturación del fleet → candidatos a glosario canónico; arch-enrich no lo crea, lo flaggea |
|
|
69
|
+
|
|
70
|
+
## 4. Cobertura y fronteras
|
|
71
|
+
|
|
72
|
+
- Términos materializados en `lib/` actual. Acreta por PR.
|
|
73
|
+
- Sin `Binding:` a tabla (gema sin datos); binding a símbolo público.
|
|
74
|
+
- Significado de negocio profundo (reglas AFIP) fuera de alcance: referencia al manual del desarrollador AFIP (ver `docs/consumed/afip.md`).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Interfaz — snoopy_afip
|
|
2
|
+
> meta: artefacto · RFC-004 · generado arch-structure · anclado a 7813cf2 · cobertura: superficie pública de `lib/snoopy_afip/`
|
|
3
|
+
|
|
4
|
+
## 1. Resumen
|
|
5
|
+
|
|
6
|
+
API Ruby pública de la gema. Módulo raíz `Snoopy` (configuración global + helpers) y cuatro clases de dominio: `AuthenticationAdapter` (WSAA), `AuthorizeAdapter` (WSFE), `Bill` (comprobante), `Client` (wrapper Savon). Constantes de dominio en `Snoopy::*` y jerarquía de errores en `Snoopy::Exception::*`.
|
|
7
|
+
|
|
8
|
+
## 2. Símbolos públicos
|
|
9
|
+
|
|
10
|
+
| símbolo | tipo | nota |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `Snoopy` | module | raíz; `extend self`; config global vía `attr_accessor` |
|
|
13
|
+
| `Snoopy.cuit` `=` | accessor | CUIT del emisor |
|
|
14
|
+
| `Snoopy.sale_point` `=` | accessor | punto de venta |
|
|
15
|
+
| `Snoopy.service_url` `=` | accessor | WSDL de WSFE |
|
|
16
|
+
| `Snoopy.auth_url` `=` | accessor | WSDL de WSAA |
|
|
17
|
+
| `Snoopy.pkey` `=` | accessor | path/contenido de clave privada |
|
|
18
|
+
| `Snoopy.cert` `=` | accessor | path/contenido de certificado |
|
|
19
|
+
| `Snoopy.default_document_type` `=` | accessor | default de `Bill#document_type` |
|
|
20
|
+
| `Snoopy.default_concept` `=` | accessor | default de `Bill#concept` |
|
|
21
|
+
| `Snoopy.default_currency` `=` | accessor | default de `Bill#currency` |
|
|
22
|
+
| `Snoopy.own_iva_cond` `=` | accessor | condición IVA propia |
|
|
23
|
+
| `Snoopy.verbose` `=` | accessor | flag de verbosidad (Savon) |
|
|
24
|
+
| `Snoopy.open_timeout` `=` | accessor | timeout de apertura; default `30` |
|
|
25
|
+
| `Snoopy.read_timeout` `=` | accessor | timeout de lectura; default `30` |
|
|
26
|
+
| `Snoopy.auth_hash` | method | `{"Token"=>…, "Sign"=>…, "Cuit"=>…}` — ver §3 |
|
|
27
|
+
| `Snoopy.bill_types` | method | array `[etiqueta, código]` de tipos habilitados |
|
|
28
|
+
| `Snoopy::Client` | class | wrapper de `Savon.client` |
|
|
29
|
+
| `Snoopy::Client#initialize(attrs)` | method | `attrs` → opciones Savon |
|
|
30
|
+
| `Snoopy::Client#call(service, args={})` | method | invoca SOAP con `Timeout`; devuelve `.body`; traduce fallos a `ServerTimeout`/`ClientError` |
|
|
31
|
+
| `Snoopy::Client#savon` `=` | accessor | cliente Savon subyacente |
|
|
32
|
+
| `Snoopy::AuthenticationAdapter` | class | flujo WSAA |
|
|
33
|
+
| `…#initialize(attrs={})` | method | `attrs[:pkey]`, `attrs[:cert]` |
|
|
34
|
+
| `…#authenticate!` | method | invoca `:login_cms`; devuelve hash con `:token`, `:sign`, `:expiration_time` |
|
|
35
|
+
| `…#build_tra` | method | arma el TRA (XML) para `wsfe` |
|
|
36
|
+
| `…#build_cms` | method | firma el TRA en PKCS7 → CMS base64 |
|
|
37
|
+
| `….generate_pkey(leng=8192)` | class method | genera clave privada RSA PEM |
|
|
38
|
+
| `….generate_certificate_request_with_ruby(pkey, subj_o, subj_cn, subj_cuit)` | class method | CSR vía OpenSSL Ruby |
|
|
39
|
+
| `….generate_certificate_request_with_bash(pkey, subj_o, subj_cn, subj_cuit)` | class method | CSR vía `openssl req` (shell) |
|
|
40
|
+
| `Snoopy::AuthorizeAdapter` | class | flujo WSFE |
|
|
41
|
+
| `…#initialize(attrs)` | method | `:bill, :cuit, :sign, :pkey, :cert, :token` |
|
|
42
|
+
| `…#authorize!` | method | invoca `:fecae_solicitar`; setea CAE/result en `bill`; devuelve bool |
|
|
43
|
+
| `…#set_bill_number!` | method | invoca `:fe_comp_ultimo_autorizado`; setea `bill.number` |
|
|
44
|
+
| `…#invoice_informed?` | method | invoca `:fe_comp_consultar`; devuelve bool |
|
|
45
|
+
| `…#auth` | method | `{"Token"=>…, "Sign"=>…, "Cuit"=>…}` de la instancia |
|
|
46
|
+
| `…#errors` `=` | accessor | errores internos de parseo (hash de excepciones) |
|
|
47
|
+
| `…#afip_errors` `=` | accessor | errores devueltos por AFIP (`code=>msg`) |
|
|
48
|
+
| `…#afip_events` `=` | accessor | eventos devueltos por AFIP |
|
|
49
|
+
| `…#afip_observations` `=` | accessor | observaciones devueltas por AFIP |
|
|
50
|
+
| `…#request` `=` / `#response` `=` | accessor | payload enviado / respuesta cruda |
|
|
51
|
+
| `Snoopy::Bill` | class | modela el comprobante |
|
|
52
|
+
| `…#initialize(attrs={})` | method | ver §4 README para `attrs` |
|
|
53
|
+
| `…#valid?` | method | corre validaciones; puebla `#errors` |
|
|
54
|
+
| `…#errors` `=` | accessor | hash `attr => [mensajes]` |
|
|
55
|
+
| `…#total` | method | neto + IVA, redondeado 2 |
|
|
56
|
+
| `…#iva_sum` | method | suma de `amount` de `alicivas` |
|
|
57
|
+
| `…#cbte_type` | method | código de comprobante según `receiver_iva_cond` |
|
|
58
|
+
| `…#cbte_asoc_type` | method | código del comprobante asociado |
|
|
59
|
+
| `…#receiver_iva_condition_id` | method | id de condición IVA del receptor (RG 5616) |
|
|
60
|
+
| `…#exchange_rate` | method | `1` si `:peso`; resto sin implementar (ver §3) |
|
|
61
|
+
| `…#approved?` / `#rejected?` / `#partial_approved?` | method | estado del resultado AFIP (`A`/`R`/`P`) |
|
|
62
|
+
| `…#to_h` / `#to_hash` | method | volcado de instance vars |
|
|
63
|
+
| `Snoopy::Bill::ATTRIBUTES` | const | lista de `attr_accessor` del comprobante |
|
|
64
|
+
| `Snoopy::Bill::TAX_ATTRIBUTES` | const | `[:id, :amount, :taxeable_base]` |
|
|
65
|
+
| `Snoopy::Bill::ATTRIBUTES_PRECENSE` | const | atributos requeridos en validación |
|
|
66
|
+
| `Snoopy::CBTE_TYPE` `Snoopy::BILL_TYPE` `Snoopy::CONCEPTS` `Snoopy::DOCUMENTS` `Snoopy::CURRENCY` `Snoopy::ALIC_IVA` `Snoopy::IVA_COND` `Snoopy::IVA_COND_RECEIVER` | const | tablas de dominio (ver `docs/data/` n/a → enriquecer en glosario) |
|
|
67
|
+
| `Snoopy::RESPONSABLE_INSCRIPTO` `…_MONOTRIBUTO` `CONSUMIDOR_FINAL` `IVA_SUJETO_EXENTO` `IVA_NO_ALCANZADO` | const | símbolos de condición IVA |
|
|
68
|
+
| `Snoopy::SNOOPY_SSL_VERSION` | const | versión TLS (de `ENV['SNOOPY_SSL_VERSION']`, default `:TLSv1`) — ver `docs/config/` |
|
|
69
|
+
| `Snoopy::Exception::*` | module/class | jerarquía de errores — ver `docs/errors/` |
|
|
70
|
+
| `String#underscore` `Hash#symbolize_keys[!]` `Hash#deep_symbolize_keys` `Hash#underscore_keys[!]` `Float#round_with_precision` `Float#round_up_with_precision` | core_ext | **monkey-patches globales** sobre `String`/`Hash`/`Float` — contaminan el host (ver §4) |
|
|
71
|
+
|
|
72
|
+
## 3. Inferencias
|
|
73
|
+
|
|
74
|
+
| ítem | confidence | a verificar |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `Bill#exchange_rate` solo resuelve `:peso` (`return 1`); el resto del método está comentado → devuelve `nil` para moneda extranjera | declared | confirmar si es intencional o feature incompleta |
|
|
77
|
+
| `Snoopy.auth_hash` usa `Snoopy::TOKEN` / `Snoopy::SIGN` (constantes **no definidas** en el código leído) | declared | `auth_hash` levantaría `NameError`; `AuthorizeAdapter#auth` es la vía viva — confirmar si `auth_hash` es legacy muerto |
|
|
78
|
+
| `core_ext` define métodos solo `unless method_defined?` (Hash/String) pero `Float` y `String#underscore` se redefinen siempre | declared | colisión potencial con ActiveSupport en hosts Rails |
|
|
79
|
+
|
|
80
|
+
## 4. Cobertura y fronteras
|
|
81
|
+
|
|
82
|
+
- **Significado de negocio** de constantes/atributos → fuera de alcance (va a `docs/glossary/`, arch-enrich).
|
|
83
|
+
- **Errores** detallados → `docs/errors/` (RFC-020).
|
|
84
|
+
- **Config runtime** (`ENV`, accessors) → `docs/config/` (RFC-012).
|
|
85
|
+
- **core_ext globales**: la gema parchea `String`/`Hash`/`Float` del host — superficie pública de facto aunque no sea el API previsto; el impacto (colisión con ActiveSupport) es concern de comportamiento → arch-enrich.
|
|
86
|
+
- `Snoopy::Exception::ServerTimeout < Timeout::Error` (no hereda de `Snoopy::Exception::Exception`) — detalle en `docs/errors/`.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Test — snoopy_afip
|
|
2
|
+
> meta: artefacto · RFC-013 · generado arch-structure · enriquecido arch-enrich · anclado a 7813cf2 (suite reescrita en fix/specs-and-lock) · cobertura: inventario estructural; §e-§h 4/4
|
|
3
|
+
|
|
4
|
+
## 1. Resumen
|
|
5
|
+
|
|
6
|
+
Suite RSpec en `spec/`, **reescrita contra la API actual y verde** (`21 examples, 0 failures`). Corre bajo **Ruby 2.7.x** (runtime del consumidor `argentina_invoice_service`). En **Ruby 3.x la gema no carga**: el stack de savon 2.12 (httpi 2.x) depende de `kconv` (removido de stdlib en 3.x) y de `Rack::Utils::HeaderHash` (removido en rack 3) → correr en 3.x requiere el upgrade de savon (gated, ver #13/roadmap).
|
|
7
|
+
|
|
8
|
+
## 2.a Suites, frameworks y niveles
|
|
9
|
+
|
|
10
|
+
| framework | archivo | propósito | nivel |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| RSpec | `spec/snoopy_afip/bill_spec.rb` | `Bill`: cbte_type, iva_sum/total, exchange_rate, receiver_iva_condition_id, estado del resultado, `valid?` | unit |
|
|
13
|
+
| RSpec | `spec/snoopy_afip/authorize_adapter_spec.rb` | `AuthorizeAdapter`: `auth`, y regresión de rescues de parseo (#10/#16) | unit |
|
|
14
|
+
| RSpec | `spec/snoopy_afip/authentication_adapter_spec.rb` | `AuthenticationAdapter`: `build_tra`, credenciales de instancia | unit |
|
|
15
|
+
| RSpec | `spec/snoopy_afip/exceptions_spec.rb` | jerarquía `Snoopy::Exception` + paraguas `Error` (regresión #14) | unit |
|
|
16
|
+
| RSpec | `spec/spec_helper.rb` | bootstrap + config base de homologación | — |
|
|
17
|
+
|
|
18
|
+
## 2.b Comando de corrida
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle exec rspec # bajo Ruby 2.7.x
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
No hay CI declarado (sin `.github/workflows/`, `.circleci/`, `bin/ci`, `config/ci.rb`). Corrida solo local. Dev-deps de test: `rspec ~> 3.13`, `activesupport` (gemspec); pin `rack ~> 2.2` (Gemfile, requerido por httpi 2.x).
|
|
25
|
+
|
|
26
|
+
## 2.c Fixtures / Factories
|
|
27
|
+
|
|
28
|
+
- **Sin fixtures en disco.** Los specs son unit puros: construyen `Bill`/adapters con placeholders inline; no requieren `spec/fixtures/pkey`/`cert.crt` ni llamadas reales a AFIP.
|
|
29
|
+
- Sin FactoryBot, sin VCR. Las URLs de homologación se setean en `spec_helper.rb` pero ninguna prueba abre conexión (Savon es lazy salvo el build del cliente, que no conecta).
|
|
30
|
+
- `Bill#valid?` necesita `blank?`/`present?` → `spec_helper` carga `active_support/core_ext/object/blank`.
|
|
31
|
+
|
|
32
|
+
## 2.d Configuración de coverage
|
|
33
|
+
|
|
34
|
+
Ninguna (sin `.simplecov` / `SimpleCov.start`). Sin umbral declarado.
|
|
35
|
+
|
|
36
|
+
## 2.e Gaps de cobertura
|
|
37
|
+
|
|
38
|
+
**Cubierto** (unit, sin red): lógica de `Bill` (cálculos, validaciones, mapeos de dominio), jerarquía de excepciones + paraguas, `build_tra`, y el **comportamiento de los rescues de parseo** (no explotan, registran String).
|
|
39
|
+
|
|
40
|
+
**NO cubierto** (requiere mock de Savon / VCR / homologación real):
|
|
41
|
+
- `authorize!` / `set_bill_number!` / `invoice_informed?` — el camino feliz contra WSFE (no se mockea la respuesta SOAP).
|
|
42
|
+
- `authenticate!` / `build_cms` — firma CMS con cert/pkey reales.
|
|
43
|
+
- `parse_*` con respuestas AFIP **válidas** (solo se testea el path de error).
|
|
44
|
+
- `core_ext` (`round_with_precision`, `deep_symbolize_keys`, `underscore`).
|
|
45
|
+
|
|
46
|
+
## 2.f Contract-assessment
|
|
47
|
+
|
|
48
|
+
| contrato público | test? |
|
|
49
|
+
|---|---|
|
|
50
|
+
| operaciones (RFC-003) | n/a (gema sin superficie) |
|
|
51
|
+
| dependencias consumidas WSAA/WSFE (RFC-018) | **parcial** — `build_tra` sí; las llamadas SOAP no se ejercitan (sin mock/VCR) |
|
|
52
|
+
| errores públicos (RFC-020) | **sí** — jerarquía `Snoopy::Exception::*` + paraguas `Error` + comportamiento de los rescues |
|
|
53
|
+
|
|
54
|
+
## 2.g Link a incidente
|
|
55
|
+
|
|
56
|
+
- `exceptions_spec.rb` fija la jerarquía de `ServerTimeout` (regresión de **#14**).
|
|
57
|
+
- `authorize_adapter_spec.rb` fija que los rescues de parseo no explotan y registran String (regresión de **#10/#16** — el diseño "No Explota" que nació de un incidente real con cambios de formato de AFIP).
|
|
58
|
+
|
|
59
|
+
## 2.h PII en fixtures
|
|
60
|
+
|
|
61
|
+
- Sin fixtures con PII (no hay fixtures en disco). `pkey`/`cert`/`cuit` en specs son placeholders inline (`/tmp/pkey`, `"20111111112"`), nunca material real.
|
|
62
|
+
|
|
63
|
+
## 3. Inferencias
|
|
64
|
+
|
|
65
|
+
| ítem | confidence | a verificar |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| La gema no carga en Ruby 3.x (kconv/httpi 2.x, Rack::Utils::HeaderHash/rack 3) | declared | desbloquea con el upgrade de savon a 2.15+ (httpi 4) — gated |
|
|
68
|
+
| El lock está resuelto para Ruby 2.7 (nokogiri 1.15.7, rack 2.2) | declared | un dev en Ruby 3.x no podrá `bundle install` hasta el upgrade |
|
|
69
|
+
|
|
70
|
+
## 4. Cobertura y fronteras
|
|
71
|
+
|
|
72
|
+
- El contenido detallado de cada test queda en el código.
|
|
73
|
+
- Falta cobertura de los caminos felices contra AFIP (necesita mock de Savon o VCR) — próximo incremento.
|
|
74
|
+
- La corrida en Ruby 3.x está bloqueada por el stack de dependencias, no por los specs.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Topología — snoopy_afip
|
|
2
|
+
> meta: artefacto · RFC-006 · generado arch-structure · anclado a 7813cf2 · cobertura: deps de runtime + servicios externos AFIP
|
|
3
|
+
|
|
4
|
+
## 1. Resumen
|
|
5
|
+
|
|
6
|
+
Gema cliente SOAP. Una sola dependencia de runtime declarada en el `.gemspec` (`savon`), que arrastra el stack SOAP (httpi, nori, gyoku, akami, wasabi, nokogiri). Consume dos servicios SOAP externos de AFIP: WSAA (auth) y WSFE (facturación). No corre como proceso propio: se embebe en el host (típicamente una app Rails).
|
|
7
|
+
|
|
8
|
+
## 2. Dependencias
|
|
9
|
+
|
|
10
|
+
| nombre | versión | rol |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `savon` | `~> 2.12.1` (gemspec) · `2.12.0` (lock) | cliente SOAP — única dep de runtime declarada |
|
|
13
|
+
| `httpi` | `2.4.4` (lock, transitiva) | capa HTTP de savon |
|
|
14
|
+
| `nori` | `2.6.0` (lock, transitiva) | XML→Hash (usado directo en `AuthenticationAdapter`) |
|
|
15
|
+
| `nokogiri` | `1.10.9` (lock, transitiva) | parser XML; usado directo en `build_tra` (`Nokogiri::XML::Builder`) |
|
|
16
|
+
| `gyoku` `akami` `wasabi` `builder` `rack` `socksify` `mini_portile2` | ver lock | transitivas de savon |
|
|
17
|
+
| `OpenSSL` (stdlib) | — | firma CMS/PKCS7, generación de clave/CSR |
|
|
18
|
+
| `Timeout` (stdlib) | — | corta llamadas SOAP (`Snoopy::Client#call`) |
|
|
19
|
+
|
|
20
|
+
## 3. Grafo
|
|
21
|
+
|
|
22
|
+
```mermaid
|
|
23
|
+
flowchart LR
|
|
24
|
+
Host["App host (Rails)"] -->|require| Snoopy["snoopy_afip"]
|
|
25
|
+
Snoopy -->|Savon.client| Savon["savon (SOAP)"]
|
|
26
|
+
Savon -->|HTTP/SOAP| WSAA["AFIP WSAA<br>login_cms"]
|
|
27
|
+
Savon -->|HTTP/SOAP| WSFE["AFIP WSFE<br>fecae_solicitar / fe_comp_*"]
|
|
28
|
+
Snoopy -->|firma CMS| OpenSSL["OpenSSL (stdlib)"]
|
|
29
|
+
Snoopy -->|build_tra| Nokogiri["nokogiri"]
|
|
30
|
+
Snoopy -->|parse| Nori["nori"]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 4. Modos de ejecución
|
|
34
|
+
|
|
35
|
+
| modo | aplica | nota |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| librería embebida | sí | se usa dentro del proceso del host; sin daemon/worker propio |
|
|
38
|
+
| web / worker / cron propios | no | la gema no define procesos |
|
|
39
|
+
|
|
40
|
+
## 5. Cobertura y fronteras
|
|
41
|
+
|
|
42
|
+
- **`Gemfile.lock` divergente** (ver `docs/test/` y nota global): el lock fija `snoopy_afip (4.0.1)` mientras `version.rb` dice `4.3.0`, y lista en `PATH specs` deps (`akami`, `nokogiri`, `nori`, `wasabi`) que el `.gemspec` **actual** no declara explícitamente (solo `savon`). El lock parece no regenerado tras cambios de gemspec/versión. Versiones de la tabla tomadas del lock como mejor evidencia disponible, marcadas.
|
|
43
|
+
- Servicios AFIP externos (WSAA/WSFE): contrato consumido en `docs/consumed/` (RFC-018).
|
|
44
|
+
- Topología upstream de AFIP (infra del organismo) fuera de alcance.
|
|
@@ -114,7 +114,7 @@ module Snoopy
|
|
|
114
114
|
[obs].flatten.each { |ob| afip_observations[ob[:code]] = ob[:msg] }
|
|
115
115
|
end
|
|
116
116
|
rescue => e
|
|
117
|
-
errors
|
|
117
|
+
@errors[:observation_parser] = Snoopy::Exception::AuthorizeAdapter::ObservationParser.new(e.message, e.backtrace).message
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
def parse_events(fecae_events)
|
|
@@ -122,7 +122,7 @@ module Snoopy
|
|
|
122
122
|
[events].flatten.each { |event| afip_events[event[:code]] = event[:msg] }
|
|
123
123
|
end
|
|
124
124
|
rescue => e
|
|
125
|
-
errors
|
|
125
|
+
@errors[:events_parser] = Snoopy::Exception::AuthorizeAdapter::EventsParser.new(e.message, e.backtrace).message
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
def parse_errors(fecae_errors)
|
|
@@ -130,7 +130,7 @@ module Snoopy
|
|
|
130
130
|
[errores].flatten.each { |error| afip_errors[error[:code]] = error[:msg] }
|
|
131
131
|
end
|
|
132
132
|
rescue => e
|
|
133
|
-
errors
|
|
133
|
+
@errors[:error_parser] = Snoopy::Exception::AuthorizeAdapter::ErrorParser.new(e.message, e.backtrace).message
|
|
134
134
|
end
|
|
135
135
|
|
|
136
136
|
def parse_fecae_solicitar_response
|
|
@@ -154,7 +154,7 @@ module Snoopy
|
|
|
154
154
|
parse_errors(fecae_result[:errors]) if fecae_result.has_key? :errors
|
|
155
155
|
parse_events(fecae_result[:events]) if fecae_result.has_key? :events
|
|
156
156
|
rescue => e
|
|
157
|
-
@errors
|
|
157
|
+
@errors[:fecae_response_parser] = Snoopy::Exception::AuthorizeAdapter::FecaeResponseParser.new(e.message, e.backtrace).message
|
|
158
158
|
end
|
|
159
159
|
end
|
|
160
160
|
|
|
@@ -179,7 +179,7 @@ module Snoopy
|
|
|
179
179
|
self.parse_errors(fe_comp_consultar_result[:errors]) if fe_comp_consultar_result and fe_comp_consultar_result.has_key? :errors
|
|
180
180
|
self.parse_events(fe_comp_consultar_result[:events]) if fe_comp_consultar_result and fe_comp_consultar_result.has_key? :events
|
|
181
181
|
rescue => e
|
|
182
|
-
@errors
|
|
182
|
+
@errors[:fecomp_consult_response_parser] = Snoopy::Exception::AuthorizeAdapter::FecompConsultResponseParser.new(e.message, e.backtrace).message
|
|
183
183
|
end
|
|
184
184
|
|
|
185
185
|
def client_configuration
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
require "timeout" # ServerTimeout < Timeout::Error; en Ruby 3.4+ timeout es default gem no autocargado
|
|
2
|
+
|
|
1
3
|
module Snoopy
|
|
2
4
|
module Exception
|
|
3
5
|
|
|
6
|
+
# Paraguas de todos los errores que emite la gema. Se incluye en la base
|
|
7
|
+
# `Exception` (y por herencia en todas sus subclases) y en `ServerTimeout`,
|
|
8
|
+
# que debe seguir siendo un `Timeout::Error` por compatibilidad hacia atrás.
|
|
9
|
+
# Permite `rescue Snoopy::Exception::Error` para atrapar cualquier error de
|
|
10
|
+
# la gema, incluido el timeout, sin romper `rescue Timeout::Error`.
|
|
11
|
+
module Error; end
|
|
12
|
+
|
|
4
13
|
class Exception < ::StandardError
|
|
14
|
+
include Error
|
|
15
|
+
|
|
5
16
|
attr_accessor :backtrace
|
|
6
17
|
|
|
7
18
|
def initialize(msg, backtrace)
|
|
@@ -17,6 +28,7 @@ module Snoopy
|
|
|
17
28
|
end
|
|
18
29
|
|
|
19
30
|
class ServerTimeout < Timeout::Error
|
|
31
|
+
include Error
|
|
20
32
|
end
|
|
21
33
|
|
|
22
34
|
module AuthenticationAdapter
|
data/lib/snoopy_afip/version.rb
CHANGED
data/lib/snoopy_afip.rb
CHANGED
data/skill/SKILL.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: snoopy-afip
|
|
3
|
+
description: >
|
|
4
|
+
Gema Ruby `snoopy_afip` — adaptador de Facturación Electrónica de AFIP
|
|
5
|
+
(Argentina). Resuelve los servicios SOAP WSAA (autenticación: token/sign a
|
|
6
|
+
partir de cert+pkey) y WSFE (autorización de comprobantes: devuelve CAE) vía
|
|
7
|
+
`savon`. Activá esta skill cuando trabajes con emisión de facturas/notas de
|
|
8
|
+
crédito electrónicas argentinas, autenticación contra AFIP, el módulo
|
|
9
|
+
`Snoopy`, o las clases `AuthenticationAdapter` / `AuthorizeAdapter` / `Bill`.
|
|
10
|
+
triggers:
|
|
11
|
+
- "facturación electrónica AFIP"
|
|
12
|
+
- "emitir factura electrónica argentina"
|
|
13
|
+
- "autenticar contra WSAA"
|
|
14
|
+
- "autorizar comprobante WSFE"
|
|
15
|
+
- "obtener CAE de AFIP"
|
|
16
|
+
- "usar la gema snoopy_afip"
|
|
17
|
+
- "Snoopy::AuthorizeAdapter / Snoopy::Bill"
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# snoopy_afip — skill de agente
|
|
21
|
+
|
|
22
|
+
Contrato resumido de la gema. El detalle vive en `docs/<capa>/` (anclado a `4.3.0` / commit `7813cf2`); esta skill indexa y resume.
|
|
23
|
+
|
|
24
|
+
## Qué es / cuándo usar
|
|
25
|
+
|
|
26
|
+
Adaptador de la Facturación Electrónica de AFIP. Úsala para autenticar (WSAA) y autorizar comprobantes (WSFE) desde Ruby. Se configura en el host vía `Snoopy.<attr> = …` y se opera con tres clases. No corre como proceso propio: es librería embebida.
|
|
27
|
+
|
|
28
|
+
## Contrato resumido (piso mínimo)
|
|
29
|
+
|
|
30
|
+
**Flujo típico** (autenticar → numerar → autorizar):
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
auth = Snoopy::AuthenticationAdapter.new(pkey: pkey, cert: cert)
|
|
34
|
+
creds = auth.authenticate! # {token, sign, expiration_time} (12h)
|
|
35
|
+
|
|
36
|
+
bill = Snoopy::Bill.new(cuit:, sale_point:, concept:, document_type:, document_num:,
|
|
37
|
+
issuer_iva_cond:, receiver_iva_cond:, receiver_iva_condition:,
|
|
38
|
+
total_net:, alicivas:) # alicivas: [{id:, amount:, taxeable_base:}]
|
|
39
|
+
adapter = Snoopy::AuthorizeAdapter.new(bill:, pkey:, cert:, cuit:,
|
|
40
|
+
token: creds[:token], sign: creds[:sign])
|
|
41
|
+
adapter.set_bill_number! # último autorizado + 1
|
|
42
|
+
adapter.authorize! # setea bill.cae / bill.result
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Símbolos clave:** `Snoopy` (config global), `Snoopy::AuthenticationAdapter` (WSAA), `Snoopy::AuthorizeAdapter` (WSFE), `Snoopy::Bill` (comprobante), `Snoopy::Client` (wrapper Savon). Tablas de dominio: `Snoopy::BILL_TYPE`, `ALIC_IVA`, `IVA_COND_RECEIVER`, `DOCUMENTS`, `CURRENCY`.
|
|
46
|
+
|
|
47
|
+
**Gotchas (load-bearing):**
|
|
48
|
+
- `receiver_iva_cond` (key de `BILL_TYPE`) determina el `cbte_type` — distinto de `issuer_iva_cond` y de `receiver_iva_condition` (RG 5616). Ver glosario.
|
|
49
|
+
- **Tras timeout en `authorize!` no reintentar a ciegas**: el comprobante pudo emitirse en AFIP — verificar con `invoice_informed?` (`docs/consumed/afip.md §c`).
|
|
50
|
+
- AFIP exige TLS ≥1.2; el default `SNOOPY_SSL_VERSION=:TLSv1` quedó desactualizado (`docs/config/configuracion.md §f`).
|
|
51
|
+
- `pkey`/`cert`/`cuit` son secretos: setear en el host, nunca commitear.
|
|
52
|
+
- La gema **parchea globalmente** `String`/`Hash`/`Float` (core_ext) — posible colisión con ActiveSupport.
|
|
53
|
+
|
|
54
|
+
## Índice de artefactos
|
|
55
|
+
|
|
56
|
+
| capa | path | nota |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| interfaz | [`docs/interface/interface.md`](../docs/interface/interface.md) | API Ruby pública |
|
|
59
|
+
| comportamiento | [`docs/behavior/behavior.md`](../docs/behavior/behavior.md) | secuencias WSAA/WSFE |
|
|
60
|
+
| glosario | [`docs/glossary/glossary.md`](../docs/glossary/glossary.md) | términos AFIP |
|
|
61
|
+
| consumidas | [`docs/consumed/afip.md`](../docs/consumed/afip.md) | SOAP WSAA + WSFE |
|
|
62
|
+
| errores | [`docs/errors/errors.md`](../docs/errors/errors.md) | excepciones + política |
|
|
63
|
+
| configuración | [`docs/config/configuracion.md`](../docs/config/configuracion.md) | opciones runtime |
|
|
64
|
+
| topología | [`docs/topology/topology.md`](../docs/topology/topology.md) | deps + externos |
|
|
65
|
+
| test | [`docs/test/testing.md`](../docs/test/testing.md) | suite + gaps |
|
|
66
|
+
| operaciones (api) · datos · eventos · multi-tenancy | n/a | gema SOAP sin HTTP/DB/eventos |
|
|
67
|
+
|
|
68
|
+
## Uso correcto / gotchas
|
|
69
|
+
|
|
70
|
+
- Homologación vs producción se elige por `auth_url`/`service_url` — riesgo de emitir contra prod por error.
|
|
71
|
+
- Errores de negocio de AFIP **no** levantan excepción: se leen en `afip_errors`/`afip_events`/`afip_observations`.
|
|
72
|
+
- La suite RSpec actual está desactualizada respecto al código (ver `docs/test/testing.md`) — no asumir cobertura.
|
data/skills.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
mcps:
|
|
2
|
+
- github
|
|
3
|
+
- clickup
|
|
4
|
+
skills:
|
|
5
|
+
multi-vendor-feedback:
|
|
6
|
+
yard:
|
|
7
|
+
quality-code:
|
|
8
|
+
gem-release:
|
|
9
|
+
arch-structure:
|
|
10
|
+
arch-compose:
|
|
11
|
+
arch-enrich:
|
|
12
|
+
skill-feedback:
|
|
13
|
+
agent-issue:
|
|
14
|
+
bug-report:
|
|
15
|
+
dev-flow:
|
|
16
|
+
documentation-writer:
|
|
17
|
+
matrix-element:
|
data/snoopy_afip.gemspec
CHANGED
|
@@ -7,9 +7,8 @@ Gem::Specification.new do |s|
|
|
|
7
7
|
s.name = "snoopy_afip"
|
|
8
8
|
s.version = Snoopy::VERSION
|
|
9
9
|
|
|
10
|
-
s.
|
|
10
|
+
s.required_ruby_version = ">= 2.5"
|
|
11
11
|
s.authors = ["g.edera"]
|
|
12
|
-
s.date = "2017-06-29"
|
|
13
12
|
s.description = "Adaptador para Web Service de Facturación Electrónica Argentina (AFIP)"
|
|
14
13
|
s.email = "gab.edera@gmail.com"
|
|
15
14
|
s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
@@ -18,7 +17,10 @@ Gem::Specification.new do |s|
|
|
|
18
17
|
s.require_paths = ["lib"]
|
|
19
18
|
# s.rubygems_version = "1.8.25"
|
|
20
19
|
s.summary = "Adaptador AFIP wsfe."
|
|
21
|
-
s.test_files = ["spec
|
|
20
|
+
s.test_files = Dir["spec/**/*"]
|
|
22
21
|
|
|
23
22
|
s.add_dependency 'savon', '~> 2.12.1'
|
|
23
|
+
|
|
24
|
+
s.add_development_dependency 'rspec', '~> 3.13'
|
|
25
|
+
s.add_development_dependency 'activesupport' # Bill#valid? usa blank?/present?
|
|
24
26
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + "/../spec_helper")
|
|
2
|
+
|
|
3
|
+
RSpec.describe Snoopy::AuthenticationAdapter do
|
|
4
|
+
describe "#build_tra" do
|
|
5
|
+
it "construye el TRA (loginTicketRequest) para el servicio wsfe" do
|
|
6
|
+
xml = described_class.new.build_tra
|
|
7
|
+
expect(xml).to include("loginTicketRequest")
|
|
8
|
+
expect(xml).to include("wsfe")
|
|
9
|
+
expect(xml).to include("uniqueId")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe "credenciales de instancia" do
|
|
14
|
+
it "expone pkey y cert recibidos" do
|
|
15
|
+
auth = described_class.new(pkey: "/tmp/pkey", cert: "/tmp/cert")
|
|
16
|
+
expect(auth.pkey).to eq("/tmp/pkey")
|
|
17
|
+
expect(auth.cert).to eq("/tmp/cert")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + "/../spec_helper")
|
|
2
|
+
|
|
3
|
+
RSpec.describe Snoopy::AuthorizeAdapter do
|
|
4
|
+
subject(:adapter) do
|
|
5
|
+
described_class.new(bill: nil, cuit: "20111111112", sign: "SIGN", token: "TOKEN",
|
|
6
|
+
pkey: "/tmp/pkey", cert: "/tmp/cert")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
describe "#auth" do
|
|
10
|
+
it "arma el hash de credenciales para AFIP" do
|
|
11
|
+
expect(adapter.auth).to eq("Token" => "TOKEN", "Sign" => "SIGN", "Cuit" => "20111111112")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Regresión #10/#16: los rescues de parseo registraban con `errors << ...`
|
|
16
|
+
# sobre un Hash (NoMethodError) y la línea 182 usaba una constante mal
|
|
17
|
+
# namespaceada (NameError). Ahora registran en @errors un String (.message)
|
|
18
|
+
# y NO propagan. Se dispara pasando datos malformados (nil) a cada parser.
|
|
19
|
+
describe "rescues de parseo (no explotan)" do
|
|
20
|
+
{
|
|
21
|
+
parse_errors: :error_parser,
|
|
22
|
+
parse_events: :events_parser,
|
|
23
|
+
parse_observations: :observation_parser
|
|
24
|
+
}.each do |method, key|
|
|
25
|
+
it "##{method} con dato inválido registra un String en errors[#{key.inspect}] sin levantar" do
|
|
26
|
+
expect { adapter.public_send(method, nil) }.not_to raise_error
|
|
27
|
+
expect(adapter.errors[key]).to be_a(String)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "parse_fe_comp_consultar_response con response vacío no levanta NameError" do
|
|
32
|
+
adapter.response = {}
|
|
33
|
+
expect { adapter.parse_fe_comp_consultar_response }.not_to raise_error
|
|
34
|
+
expect(adapter.errors[:fecomp_consult_response_parser]).to be_a(String)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|