arca.rb 1.0.0

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.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +8 -0
  3. data/.devcontainer/devcontainer.json +12 -0
  4. data/.github/dependabot.yml +12 -0
  5. data/.github/workflows/ci.yml +16 -0
  6. data/.github/workflows/release.yml +37 -0
  7. data/.gitignore +8 -0
  8. data/.mise.toml +2 -0
  9. data/.rubocop.yml +8 -0
  10. data/.ruby-version +1 -0
  11. data/CHANGELOG.md +19 -0
  12. data/CONTRIBUTING.md +48 -0
  13. data/Gemfile +4 -0
  14. data/LICENSE +22 -0
  15. data/README.md +209 -0
  16. data/Rakefile +10 -0
  17. data/SECURITY.md +14 -0
  18. data/arca.gemspec +40 -0
  19. data/bin/console +7 -0
  20. data/lib/arca/client.rb +29 -0
  21. data/lib/arca/core_ext/hash.rb +13 -0
  22. data/lib/arca/errors/error.rb +13 -0
  23. data/lib/arca/errors/network_error.rb +15 -0
  24. data/lib/arca/errors/response_error.rb +18 -0
  25. data/lib/arca/errors/server_error.rb +6 -0
  26. data/lib/arca/persona_service_a100.rb +51 -0
  27. data/lib/arca/persona_service_a4.rb +34 -0
  28. data/lib/arca/persona_service_a5.rb +46 -0
  29. data/lib/arca/type_conversions.rb +44 -0
  30. data/lib/arca/version.rb +5 -0
  31. data/lib/arca/w_cons_declaracion.rb +66 -0
  32. data/lib/arca/ws_constancia_inscripcion.rb +34 -0
  33. data/lib/arca/wsaa.rb +140 -0
  34. data/lib/arca/wscdc.rb +149 -0
  35. data/lib/arca/wsfe.rb +205 -0
  36. data/lib/arca/wsfecred.rb +438 -0
  37. data/lib/arca/wsrgiva.rb +72 -0
  38. data/lib/arca.rb +31 -0
  39. data/test/arca/client_test.rb +43 -0
  40. data/test/arca/core_ext/hash_test.rb +15 -0
  41. data/test/arca/persona_service_a100_test.rb +45 -0
  42. data/test/arca/persona_service_a4_test.rb +31 -0
  43. data/test/arca/persona_service_a5_test.rb +31 -0
  44. data/test/arca/test.crt +19 -0
  45. data/test/arca/test.key +28 -0
  46. data/test/arca/type_conversions_test.rb +43 -0
  47. data/test/arca/w_cons_declaracion_test.rb +86 -0
  48. data/test/arca/ws_constancia_inscripcion_test.rb +87 -0
  49. data/test/arca/wsaa_test.rb +80 -0
  50. data/test/arca/wscdc_test.rb +103 -0
  51. data/test/arca/wsfe_test.rb +319 -0
  52. data/test/arca/wsfecred_test.rb +162 -0
  53. data/test/arca/wsrgiva_test.rb +91 -0
  54. data/test/fixtures/wconsdeclaracion/detallada_estado/success.xml +24 -0
  55. data/test/fixtures/wconsdeclaracion/detallada_lista_declaraciones/por_fecha_success.xml +39 -0
  56. data/test/fixtures/wconsdeclaracion/detallada_lista_declaraciones/por_id_inexistente.xml +16 -0
  57. data/test/fixtures/wconsdeclaracion/detallada_lista_declaraciones/por_id_success.xml +29 -0
  58. data/test/fixtures/wconsdeclaracion/dummy/success.xml +12 -0
  59. data/test/fixtures/wconsdeclaracion/wconsdeclaracion.wsdl +2976 -0
  60. data/test/fixtures/ws_sr_constancia_inscripcion/dummy/success.xml +11 -0
  61. data/test/fixtures/ws_sr_constancia_inscripcion/get_persona/failure.xml +35 -0
  62. data/test/fixtures/ws_sr_constancia_inscripcion/get_persona/fault.xml +8 -0
  63. data/test/fixtures/ws_sr_constancia_inscripcion/get_persona/success.xml +53 -0
  64. data/test/fixtures/ws_sr_constancia_inscripcion/ws_constancia_inscripcion.wsdl +230 -0
  65. data/test/fixtures/ws_sr_padron_a100/company_types/success.xml +23 -0
  66. data/test/fixtures/ws_sr_padron_a100/dummy/success.xml +11 -0
  67. data/test/fixtures/ws_sr_padron_a100/jurisdictions/success.xml +31 -0
  68. data/test/fixtures/ws_sr_padron_a100/public_organisms/success.xml +23 -0
  69. data/test/fixtures/ws_sr_padron_a100.wsdl +125 -0
  70. data/test/fixtures/ws_sr_padron_a4/dummy/success.xml +11 -0
  71. data/test/fixtures/ws_sr_padron_a4/get_persona/success.xml +133 -0
  72. data/test/fixtures/ws_sr_padron_a4.wsdl +229 -0
  73. data/test/fixtures/ws_sr_padron_a5/dummy/success.xml +11 -0
  74. data/test/fixtures/ws_sr_padron_a5/get_persona/success.xml +62 -0
  75. data/test/fixtures/ws_sr_padron_a5.wsdl +283 -0
  76. data/test/fixtures/wsaa/login_cms/fault.xml +12 -0
  77. data/test/fixtures/wsaa/login_cms/success.xml +16 -0
  78. data/test/fixtures/wsaa/login_cms/token_expirado.xml +14 -0
  79. data/test/fixtures/wsaa/wsaa.wsdl +103 -0
  80. data/test/fixtures/wscdc/comprobante_constatar/success.xml +22 -0
  81. data/test/fixtures/wscdc/comprobante_constatar/with_errors.xml +28 -0
  82. data/test/fixtures/wscdc/comprobante_dummy/success.xml +11 -0
  83. data/test/fixtures/wscdc/comprobantes_modalidad_consultar/success.xml +22 -0
  84. data/test/fixtures/wscdc/comprobantes_tipo_consultar/success.xml +22 -0
  85. data/test/fixtures/wscdc/documentos_tipo_consultar/success.xml +16 -0
  86. data/test/fixtures/wscdc/opcionales_tipo_consultar/success.xml +14 -0
  87. data/test/fixtures/wscdc/wscdc.wsdl +305 -0
  88. data/test/fixtures/wsfe/fe_comp_consultar/success.xml +41 -0
  89. data/test/fixtures/wsfe/fe_comp_tot_x_request/success.xml +9 -0
  90. data/test/fixtures/wsfe/fe_comp_ultimo_autorizado/success.xml +11 -0
  91. data/test/fixtures/wsfe/fe_dummy/success.xml +11 -0
  92. data/test/fixtures/wsfe/fe_param_get_cotizacion/dolar.xml +13 -0
  93. data/test/fixtures/wsfe/fe_param_get_cotizacion/inexistente.xml +14 -0
  94. data/test/fixtures/wsfe/fe_param_get_ptos_venta/success.xml +22 -0
  95. data/test/fixtures/wsfe/fe_param_get_tipos_cbte/failure_1_error.xml +14 -0
  96. data/test/fixtures/wsfe/fe_param_get_tipos_cbte/failure_2_errors.xml +18 -0
  97. data/test/fixtures/wsfe/fe_param_get_tipos_cbte/success.xml +22 -0
  98. data/test/fixtures/wsfe/fe_param_get_tipos_concepto/success.xml +17 -0
  99. data/test/fixtures/wsfe/fe_param_get_tipos_doc/success.xml +16 -0
  100. data/test/fixtures/wsfe/fe_param_get_tipos_iva/success.xml +16 -0
  101. data/test/fixtures/wsfe/fe_param_get_tipos_monedas/success.xml +22 -0
  102. data/test/fixtures/wsfe/fe_param_get_tipos_opcional/success.xml +16 -0
  103. data/test/fixtures/wsfe/fe_param_get_tipos_tributos/success.xml +16 -0
  104. data/test/fixtures/wsfe/fecae_solicitar/autorizacion_1_cbte.xml +30 -0
  105. data/test/fixtures/wsfe/fecae_solicitar/autorizacion_2_cbtes.xml +41 -0
  106. data/test/fixtures/wsfe/fecae_solicitar/dos_observaciones.xml +40 -0
  107. data/test/fixtures/wsfe/fecae_solicitar/una_observacion.xml +36 -0
  108. data/test/fixtures/wsfe/fecaea_consultar/success.xml +17 -0
  109. data/test/fixtures/wsfe/fecaea_reg_informativo/informe_rtdo_parcial.xml +45 -0
  110. data/test/fixtures/wsfe/fecaea_sin_movimiento_informar/success.xml +12 -0
  111. data/test/fixtures/wsfe/fecaea_solicitar/caea_ya_otorgado.xml +18 -0
  112. data/test/fixtures/wsfe/fecaea_solicitar/error_distinto.xml +18 -0
  113. data/test/fixtures/wsfe/fecaea_solicitar/success.xml +17 -0
  114. data/test/fixtures/wsfe/wsfe.wsdl +1372 -0
  115. data/test/fixtures/wsfecred/aceptar_f_e_cred/success.xml +14 -0
  116. data/test/fixtures/wsfecred/consultar_comprobantes/success.xml +17 -0
  117. data/test/fixtures/wsfecred/consultar_cta_cte/success.xml +13 -0
  118. data/test/fixtures/wsfecred/consultar_ctas_ctes/success.xml +16 -0
  119. data/test/fixtures/wsfecred/consultar_tipos_formas_cancelacion/success.xml +14 -0
  120. data/test/fixtures/wsfecred/consultar_tipos_motivos_rechazo/success.xml +14 -0
  121. data/test/fixtures/wsfecred/consultar_tipos_retenciones/failure.xml +14 -0
  122. data/test/fixtures/wsfecred/consultar_tipos_retenciones/success.xml +14 -0
  123. data/test/fixtures/wsfecred/dummy/success.xml +11 -0
  124. data/test/fixtures/wsfecred/wsfecred.wsdl +147 -0
  125. data/test/fixtures/wsrgiva/consultar_constancia_por_lote/one_error.xml +14 -0
  126. data/test/fixtures/wsrgiva/consultar_constancia_por_lote/success.xml +26 -0
  127. data/test/fixtures/wsrgiva/dummy/success.xml +11 -0
  128. data/test/fixtures/wsrgiva/wsrgiva.wsdl +118 -0
  129. data/test/support/savon_extensions.rb +43 -0
  130. data/test/test_helper.rb +44 -0
  131. metadata +319 -0
data/lib/arca/wsfe.rb ADDED
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arca
4
+ using Arca::CoreExt::Hash
5
+
6
+ class WSFE
7
+ WSDL = {
8
+ development: "https://wswhomo.afip.gov.ar/wsfev1/service.asmx?WSDL",
9
+ production: "https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL",
10
+ test: "#{Root}/test/fixtures/wsfe/wsfe.wsdl"
11
+ }.freeze
12
+
13
+ include TypeConversions
14
+
15
+ attr_reader :wsaa, :cuit
16
+
17
+ def initialize(options = {})
18
+ @cuit = options[:cuit]
19
+ @wsaa = WSAA.new options.merge(service: "wsfe")
20
+ @client = Client.new Hash(options[:savon]).reverse_merge(wsdl: WSDL[@wsaa.env],
21
+ convert_request_keys_to: :camelcase)
22
+ end
23
+
24
+ def dummy
25
+ request :fe_dummy
26
+ end
27
+
28
+ def tipos_comprobantes
29
+ r = request :fe_param_get_tipos_cbte, auth
30
+ x2r get_array(r, :cbte_tipo), id: :integer, fch_desde: :date, fch_hasta: :date
31
+ end
32
+
33
+ def tipos_opcional
34
+ r = request :fe_param_get_tipos_opcional, auth
35
+ x2r get_array(r, :tipos_opcional), id: :integer, fch_desde: :date, fch_hasta: :date
36
+ end
37
+
38
+ def tipos_documentos
39
+ r = request :fe_param_get_tipos_doc, auth
40
+ x2r get_array(r, :doc_tipo), id: :integer, fch_desde: :date, fch_hasta: :date
41
+ end
42
+
43
+ def tipos_concepto
44
+ r = request :fe_param_get_tipos_concepto, auth
45
+ x2r get_array(r, :concepto_tipo), id: :integer, fch_desde: :date, fch_hasta: :date
46
+ end
47
+
48
+ def tipos_monedas
49
+ r = request :fe_param_get_tipos_monedas, auth
50
+ x2r get_array(r, :moneda), fch_desde: :date, fch_hasta: :date
51
+ end
52
+
53
+ def tipos_iva
54
+ r = request :fe_param_get_tipos_iva, auth
55
+ x2r get_array(r, :iva_tipo), id: :integer, fch_desde: :date, fch_hasta: :date
56
+ end
57
+
58
+ def tipos_tributos
59
+ r = request :fe_param_get_tipos_tributos, auth
60
+ x2r get_array(r, :tributo_tipo), id: :integer, fch_desde: :date, fch_hasta: :date
61
+ end
62
+
63
+ def tipos_condicion_iva_receptor
64
+ r = request :fe_param_get_condicion_iva_receptor, auth
65
+ x2r get_array(r, :condicion_iva_receptor), id: :integer
66
+ end
67
+
68
+ def puntos_venta
69
+ r = request :fe_param_get_ptos_venta, auth
70
+ # Extract standard fields: nro, fch_baja, bloqueado
71
+ # Also extract emision_tipo (EmisionTipo), pve_nombre_fantasia (pveNombreFantasia),
72
+ # and pve_dominio (pveDominio) if present in response
73
+ # Note: WSDL only shows Nro, EmisionTipo, Bloqueado, FchBaja, but actual response
74
+ # may include additional fields like pveNombreFantasia and pveDominio
75
+ x2r get_array(r, :pto_venta),
76
+ nro: :integer,
77
+ fch_baja: :date,
78
+ bloqueado: :boolean,
79
+ emision_tipo: nil, # String field, no conversion needed
80
+ pve_nombre_fantasia: nil, # String field, may not be in WSDL but could be in response
81
+ pve_dominio: nil # String field, may not be in WSDL but could be in response
82
+ end
83
+
84
+ def cotizacion(moneda_id)
85
+ request(:fe_param_get_cotizacion, auth.merge(mon_id: moneda_id))[:result_get][:mon_cotiz].to_f
86
+ end
87
+
88
+ def autorizar_comprobantes(opciones)
89
+ comprobantes = opciones[:comprobantes]
90
+ mensaje = {
91
+ "FeCAEReq" => {
92
+ "FeCabReq" => opciones.select_keys(:cbte_tipo, :pto_vta).merge(cant_reg: comprobantes.size),
93
+ "FeDetReq" => {
94
+ "FECAEDetRequest" => comprobantes.map { |comprobante| comprobante_to_request(comprobante, opciones) }
95
+ }
96
+ }
97
+ }
98
+ mensaje = r2x(mensaje, cbte_fch: :date, fch_serv_desde: :date, fch_serv_hasta: :date, fch_vto_pago: :date)
99
+ r = request :fecae_solicitar, auth.merge(mensaje)
100
+ r = Array.wrap(r[:fe_det_resp][:fecae_det_response]).map do |h|
101
+ obs = Array.wrap(h[:observaciones] ? h[:observaciones][:obs] : nil)
102
+ h.select_keys(:cae, :cae_fch_vto, :resultado).merge(cbte_nro: h[:cbte_desde], observaciones: obs)
103
+ end
104
+ x2r r, cae_fch_vto: :date, fch_serv_desde: :date, fch_serv_hasta: :date, fch_vto_pago: :date, cbte_nro: :integer,
105
+ code: :integer
106
+ end
107
+
108
+ def comprobante_to_request(comprobante, opciones)
109
+ nro = comprobante.delete :cbte_nro
110
+ iva = comprobante.delete :imp_iva
111
+ iva_receptor_id = comprobante.delete :condicion_iva_receptor_id
112
+ comprobante.delete :tributos if comprobante[:imp_trib] == 0
113
+ comprobante.merge! cbte_desde: nro, cbte_hasta: nro, "ImpIVA" => iva, "CondicionIVAReceptorId" => iva_receptor_id
114
+ comprobante.merge! periodo_asoc: opciones[:periodo_asoc] if opciones[:periodo_asoc]
115
+ comprobante
116
+ end
117
+
118
+ def solicitar_caea
119
+ convertir_rta_caea request(:fecaea_solicitar, auth.merge(periodo_para_solicitud_caea))
120
+ rescue Arca::ResponseError => e
121
+ if e.code? 15_008
122
+ consultar_caea fecha_inicio_quincena_siguiente
123
+ else
124
+ raise
125
+ end
126
+ end
127
+
128
+ def consultar_caea(fecha)
129
+ convertir_rta_caea request(:fecaea_consultar, auth.merge(periodo_para_consulta_caea(fecha)))
130
+ end
131
+
132
+ def periodo_para_solicitud_caea
133
+ periodo_para_consulta_caea fecha_inicio_quincena_siguiente
134
+ end
135
+
136
+ def periodo_para_consulta_caea(fecha)
137
+ orden = fecha.day <= 15 ? 1 : 2
138
+ { orden: orden, periodo: fecha.strftime("%Y%m") }
139
+ end
140
+
141
+ def fecha_inicio_quincena_siguiente
142
+ hoy = Date.today
143
+ hoy.day <= 15 ? hoy.change(day: 16) : hoy.next_month.change(day: 1)
144
+ end
145
+
146
+ def informar_comprobantes_caea(opciones)
147
+ comprobantes = opciones[:comprobantes]
148
+ mensaje = {
149
+ "FeCAEARegInfReq" => {
150
+ "FeCabReq" => opciones.select_keys(:cbte_tipo, :pto_vta).merge(cant_reg: comprobantes.size),
151
+ "FeDetReq" => {
152
+ "FECAEADetRequest" => comprobantes.map do |comprobante|
153
+ comprobante_to_request(comprobante.merge("CAEA" => comprobante.delete(:caea)), opciones)
154
+ end
155
+ }
156
+ }
157
+ }
158
+ r = request :fecaea_reg_informativo, auth.merge(r2x(mensaje, cbte_fch: :date))
159
+ r = Array.wrap(r[:fe_det_resp][:fecaea_det_response]).map do |h|
160
+ obs = Array.wrap(h[:observaciones] ? h[:observaciones][:obs] : nil)
161
+ h.select_keys(:caea, :resultado).merge(cbte_nro: h[:cbte_desde], observaciones: obs)
162
+ end
163
+ x2r r, cbte_nro: :integer, code: :integer
164
+ end
165
+
166
+ def informar_caea_sin_movimientos(caea, pto_vta)
167
+ request :fecaea_sin_movimiento_informar, auth.merge("CAEA" => caea, "PtoVta" => pto_vta)
168
+ end
169
+
170
+ def ultimo_comprobante_autorizado(opciones)
171
+ request(:fe_comp_ultimo_autorizado, auth.merge(opciones))[:cbte_nro].to_i
172
+ end
173
+
174
+ def consultar_comprobante(opciones)
175
+ request(:fe_comp_consultar, auth.merge(fe_comp_cons_req: opciones))[:result_get]
176
+ end
177
+
178
+ def cant_max_registros_x_lote
179
+ request(:fe_comp_tot_x_request, auth)[:reg_x_req].to_i
180
+ end
181
+
182
+ def auth
183
+ { auth: wsaa.auth.merge(cuit: cuit) }
184
+ end
185
+
186
+ private
187
+
188
+ def request(action, body = nil)
189
+ response = @client.request(action, body).to_hash[:"#{action}_response"][:"#{action}_result"]
190
+ if response[:errors]
191
+ raise ResponseError, Array.wrap(response[:errors][:err])
192
+ else
193
+ response
194
+ end
195
+ end
196
+
197
+ def get_array(response, array_element)
198
+ Array.wrap response[:result_get][array_element]
199
+ end
200
+
201
+ def convertir_rta_caea(r)
202
+ x2r r[:result_get], fch_tope_inf: :date, fch_vig_desde: :date, fch_vig_hasta: :date
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WSFeCred: Web Service Factura Electrónica de Crédito MiPyMEs (ARCA).
4
+ # Gestión de cuentas corrientes originadas por Facturas Electrónicas de Crédito (FECRED).
5
+ # Documentación: Manual para el Desarrollador FECredService v2.0.3.
6
+ #
7
+ # Security: Token/sign from WSAA are never logged; TLS is handled by Client (SSL ciphers).
8
+ # Errors: Business/format errors raise ResponseError with :code and :msg; transport raise ServerError/NetworkError.
9
+ module Arca
10
+ class WSFeCred
11
+ WSDL = {
12
+ development: "https://fwshomo.afip.gov.ar/wsfecred/FECredService?wsdl",
13
+ production: "https://serviciosjava.afip.gob.ar/wsfecred/FECredService?wsdl",
14
+ test: "#{Root}/test/fixtures/wsfecred/wsfecred.wsdl"
15
+ }.freeze
16
+
17
+ attr_reader :wsaa, :cuit
18
+
19
+ def initialize(options = {})
20
+ @cuit = normalize_cuit(options[:cuit])
21
+ @wsaa = WSAA.new options.merge(service: "wsfecred")
22
+ @client = Client.new Hash(options[:savon]).reverse_merge(
23
+ wsdl: WSDL[@wsaa.env],
24
+ soap_version: 1,
25
+ convert_request_keys_to: :camelcase
26
+ )
27
+ end
28
+
29
+ def dummy
30
+ r = raw_request(:dummy, nil)
31
+ ret = r[:return] || r
32
+ {
33
+ app_server: ret[:appserver] || ret[:app_server],
34
+ auth_server: ret[:authserver] || ret[:auth_server],
35
+ db_server: ret[:dbserver] || ret[:db_server]
36
+ }
37
+ end
38
+
39
+ # --- Operaciones de negocio ---
40
+
41
+ # Aceptar o cancelar total/parcialmente la Factura Electrónica de Crédito.
42
+ # id_cta_cte: { cod_cta_cte: long } o { id_factura: { cuit_emisor, cod_tipo_cmp, pto_vta, nro_cmp } }
43
+ # Opciones: array_confirmar_notas_dc, array_formas_cancelacion, array_retenciones,
44
+ # array_ajustes_operacion, tipo_cancelacion, importe_cancelado, importe_total_ret_pesos,
45
+ # importe_embargo_pesos, saldo_aceptado, cod_moneda, cotizacion_moneda_ult,
46
+ # informa_cbu, cbu_comprador
47
+ def aceptar_f_e_cred id_cta_cte, **opciones
48
+ body = { auth_request: auth, id_cta_cte: build_id_cta_cte(id_cta_cte) }
49
+ body.merge!(opciones_to_aceptar(opciones))
50
+ parse_operacion_response raw_request(:aceptar_f_e_cred, body), :aceptar_f_e_cred
51
+ end
52
+
53
+ # Rechazar la Cta.Cte. de una FECRED. array_motivos_rechazo: [ { cod_motivo, desc_motivo, justificacion } ]
54
+ def rechazar_f_e_cred(id_cta_cte, array_motivos_rechazo:)
55
+ motivos = Array(array_motivos_rechazo)
56
+ raise ArgumentError, "array_motivos_rechazo must have at least one motive" if motivos.empty?
57
+
58
+ body = {
59
+ auth_request: auth,
60
+ id_cta_cte: build_id_cta_cte(id_cta_cte),
61
+ array_motivos_rechazo: { motivo_rechazo: motivos }
62
+ }
63
+ parse_operacion_response raw_request(:rechazar_f_e_cred, body), :rechazar_f_e_cred
64
+ end
65
+
66
+ # Rechazar una Nota de Débito/Crédito individualmente.
67
+ def rechazar_nota_dc(id_comprobante, array_motivos_rechazo:)
68
+ motivos = Array(array_motivos_rechazo)
69
+ raise ArgumentError, "array_motivos_rechazo must have at least one motive" if motivos.empty?
70
+
71
+ body = {
72
+ auth_request: auth,
73
+ id_comprobante: build_id_comprobante(id_comprobante),
74
+ array_motivos_rechazo: { motivo_rechazo: motivos }
75
+ }
76
+ r = raw_request(:rechazar_nota_dc, body)
77
+ ret = get_return_value(r, :rechazar_nota_dc)
78
+ parse_response_errores! ret
79
+ ret
80
+ end
81
+
82
+ # Informar Factura al Agente de Depósito Colectivo. cta_agente: { cuit_agente, id_cuenta }
83
+ def informar_factura_agt_dpto_cltv(id_cta_cte, cta_agente:)
84
+ body = {
85
+ auth_request: auth,
86
+ id_cta_cte: build_id_cta_cte(id_cta_cte),
87
+ cta_agente: cta_agente
88
+ }
89
+ parse_operacion_response raw_request(:informar_factura_agt_dpto_cltv, body), :informar_factura_agt_dpto_cltv
90
+ end
91
+
92
+ # Informar cancelación total de la FECRED. array_formas_cancelacion: [ { codigo } ], importe_cancelacion
93
+ def informar_cancelacion_total_f_e_cred(id_cta_cte, array_formas_cancelacion:, importe_cancelacion:)
94
+ formas = Array(array_formas_cancelacion)
95
+ raise ArgumentError, "array_formas_cancelacion must have at least one item" if formas.empty?
96
+
97
+ body = {
98
+ auth_request: auth,
99
+ id_cta_cte: build_id_cta_cte(id_cta_cte),
100
+ array_formas_cancelacion: { codigo_descripcion: formas },
101
+ importe_cancelacion: importe_cancelacion
102
+ }
103
+ parse_operacion_response raw_request(:informar_cancelacion_total_f_e_cred, body),
104
+ :informar_cancelacion_total_f_e_cred
105
+ end
106
+
107
+ # Modificar opción de transferencia (ADC o SCA). opcion_transferencia: 'ADC' | 'SCA'
108
+ def modificar_opcion_transferencia(id_cta_cte, opcion_transferencia:)
109
+ body = {
110
+ auth_request: auth,
111
+ id_cta_cte: build_id_cta_cte(id_cta_cte),
112
+ opcion_transferencia: opcion_transferencia
113
+ }
114
+ parse_operacion_response raw_request(:modificar_opcion_transferencia, body), :modificar_opcion_transferencia
115
+ end
116
+
117
+ # Consultar comprobantes. rol_cuit_representada: 'Emisor'|'Receptor'. Filtros opcionales.
118
+ def consultar_comprobantes(rol_cuit_representada:, cuit_contraparte: nil, cod_tipo_cmp: nil,
119
+ estado_cmp: nil, fecha: nil, cod_cta_cte: nil, estado_cta_cte: nil, nro_pagina: nil)
120
+ body = { auth_request: auth, rol_cuit_representada: rol_cuit_representada }
121
+ body[:cuit_contraparte] = cuit_contraparte if cuit_contraparte
122
+ body[:cod_tipo_cmp] = cod_tipo_cmp if cod_tipo_cmp
123
+ body[:estado_cmp] = estado_cmp if estado_cmp
124
+ body[:fecha] = fecha if fecha
125
+ body[:cod_cta_cte] = cod_cta_cte if cod_cta_cte
126
+ body[:estado_cta_cte] = estado_cta_cte if estado_cta_cte
127
+ body[:nro_pagina] = nro_pagina if nro_pagina
128
+ r = raw_request(:consultar_comprobantes, body)
129
+ ret = get_return_value(r, :consultar_comprobantes)
130
+ parse_response_errores! ret
131
+ {
132
+ array_comprobantes: wrap_array(ret[:array_comprobantes]&.[](:comprobante)),
133
+ nro_pagina: ret[:nro_pagina],
134
+ hay_mas: ret[:hay_mas],
135
+ evento: ret[:evento],
136
+ array_observaciones: wrap_codigo_descripcion(ret[:array_observaciones])
137
+ }
138
+ end
139
+
140
+ # Consultar cuentas corrientes. rol_cuit_representada, filtros opcionales.
141
+ def consultar_ctas_ctes(rol_cuit_representada:, cuit_contraparte: nil, fecha: nil,
142
+ estado_cta_cte: nil, nro_pagina: nil, opcion_transferencia: nil)
143
+ body = { auth_request: auth, rol_cuit_representada: rol_cuit_representada }
144
+ body[:cuit_contraparte] = cuit_contraparte if cuit_contraparte
145
+ body[:fecha] = fecha if fecha
146
+ body[:estado_cta_cte] = estado_cta_cte if estado_cta_cte
147
+ body[:nro_pagina] = nro_pagina if nro_pagina
148
+ body[:opcion_transferencia] = opcion_transferencia if opcion_transferencia
149
+ r = raw_request(:consultar_ctas_ctes, body)
150
+ ret = get_return_value(r, :consultar_ctas_ctes)
151
+ parse_response_errores! ret
152
+ {
153
+ array_infos_cta_cte: wrap_array(ret[:array_infos_cta_cte]&.[](:info_cta_cte)),
154
+ nro_pagina: ret[:nro_pagina],
155
+ hay_mas: ret[:hay_mas],
156
+ evento: ret[:evento],
157
+ array_observaciones: wrap_codigo_descripcion(ret[:array_observaciones])
158
+ }
159
+ end
160
+
161
+ # Consultar detalle de una cuenta corriente.
162
+ def consultar_cta_cte(id_cta_cte)
163
+ body = { auth_request: auth, id_cta_cte: build_id_cta_cte(id_cta_cte) }
164
+ r = raw_request(:consultar_cta_cte, body)
165
+ ret = get_return_value(r, :consultar_cta_cte)
166
+ parse_response_errores! ret
167
+ ret.merge(cta_cte: ret[:cta_cte], array_observaciones: wrap_codigo_descripcion(ret[:array_observaciones]))
168
+ end
169
+
170
+ # Consultar cuentas del vendedor en Agentes de Depósito Colectivo.
171
+ def consultar_cuentas_en_agt_dpto_cltv
172
+ body = { auth_request: auth }
173
+ r = raw_request(:consultar_cuentas_en_agt_dpto_cltv, body)
174
+ ret = get_return_value(r, :consultar_cuentas_en_agt_dpto_cltv)
175
+ parse_response_errores! ret
176
+ {
177
+ array_cuentas_en_agente: wrap_array(ret[:array_cuentas_en_agente]&.[](:cuenta_en_agente)),
178
+ array_observaciones: wrap_codigo_descripcion(ret[:array_observaciones])
179
+ }
180
+ end
181
+
182
+ # Consultar monto obligado recepción para una CUIT y fecha de emisión.
183
+ def consultar_monto_obligado_recepcion(cuit_consultada:, fecha_emision:)
184
+ body = {
185
+ auth_request: auth,
186
+ cuit_consultada: normalize_cuit(cuit_consultada),
187
+ fecha_emision: format_date(fecha_emision)
188
+ }
189
+ r = raw_request(:consultar_monto_obligado_recepcion, body)
190
+ ret = get_return_value(r, :consultar_monto_obligado_recepcion)
191
+ parse_response_errores! ret
192
+ {
193
+ respuesta: ret[:respuesta],
194
+ monto_desde: ret[:monto_desde],
195
+ array_observaciones: wrap_codigo_descripcion(ret[:array_observaciones])
196
+ }
197
+ end
198
+
199
+ # Tipos de retenciones habilitados.
200
+ def consultar_tipos_retenciones
201
+ body = { auth_request: auth }
202
+ r = raw_request(:consultar_tipos_retenciones, body)
203
+ ret = get_return_value(r, :consultar_tipos_retenciones)
204
+ parse_response_errores! ret
205
+ wrap_array(ret[:array_tipos_retenciones]&.[](:tipo_retencion))
206
+ end
207
+
208
+ # Tipos de motivos de rechazo.
209
+ def consultar_tipos_motivos_rechazo
210
+ body = { auth_request: auth }
211
+ r = raw_request(:consultar_tipos_motivos_rechazo, body)
212
+ ret = get_return_value(r, :consultar_tipos_motivos_rechazo)
213
+ parse_response_errores! ret
214
+ wrap_codigo_descripcion(ret[:array_codigo_descripcion])
215
+ end
216
+
217
+ # Facturas informadas al Agente de Depósito Colectivo.
218
+ def consultar_facturas_agt_dpto_cltv(id_cta_cte: nil, filtro_fecha: nil)
219
+ body = { auth_request: auth }
220
+ body[:id_cta_cte] = build_id_cta_cte(id_cta_cte) if id_cta_cte
221
+ body[:filtro_fecha] = filtro_fecha if filtro_fecha
222
+ r = raw_request(:consultar_facturas_agt_dpto_cltv, body)
223
+ ret = get_return_value(r, :consultar_facturas_agt_dpto_cltv)
224
+ parse_response_errores! ret
225
+ {
226
+ array_facturas_agt_dpto_cltv: wrap_array(ret[:array_facturas_agt_dpto_cltv]&.[](:factura_informada)),
227
+ evento: ret[:evento],
228
+ array_observaciones: wrap_codigo_descripcion(ret[:array_observaciones])
229
+ }
230
+ end
231
+
232
+ # Tipos de formas de cancelación.
233
+ def consultar_tipos_formas_cancelacion
234
+ body = { auth_request: auth }
235
+ r = raw_request(:consultar_tipos_formas_cancelacion, body)
236
+ ret = get_return_value(r, :consultar_tipos_formas_cancelacion)
237
+ parse_response_errores! ret
238
+ wrap_codigo_descripcion(ret[:array_codigo_descripcion])
239
+ end
240
+
241
+ # Remitos asociados a un comprobante.
242
+ def obtener_remitos(id_comprobante)
243
+ body = { auth_request: auth, id_comprobante: build_id_comprobante(id_comprobante) }
244
+ r = raw_request(:obtener_remitos, body)
245
+ ret = get_return_value(r, :obtener_remitos)
246
+ parse_response_errores! ret
247
+ wrap_array(ret[:array_ids_remitos]&.[](:id_comprobante))
248
+ end
249
+
250
+ # Historial de estados de un comprobante.
251
+ def consultar_historial_estados_comprobante(id_comprobante)
252
+ body = { auth_request: auth, id_comprobante: build_id_comprobante(id_comprobante) }
253
+ r = raw_request(:consultar_historial_estados_comprobante, body)
254
+ ret = get_return_value(r, :consultar_historial_estados_comprobante)
255
+ parse_response_errores! ret
256
+ {
257
+ id_comprobante: ret[:id_comprobante],
258
+ array_historial_estados: wrap_array(ret[:array_historial_estados]&.[](:estado_historico))
259
+ }
260
+ end
261
+
262
+ # Historial de estados de una cuenta corriente.
263
+ def consultar_historial_estados_cta_cte(id_cta_cte)
264
+ body = { auth_request: auth, id_cta_cte: build_id_cta_cte(id_cta_cte) }
265
+ r = raw_request(:consultar_historial_estados_cta_cte, body)
266
+ ret = get_return_value(r, :consultar_historial_estados_cta_cte)
267
+ parse_response_errores! ret
268
+ {
269
+ id_cta_cte: ret[:id_cta_cte],
270
+ array_historial_estados: wrap_array(ret[:array_historial_estados]&.[](:estado_historico))
271
+ }
272
+ end
273
+
274
+ # Tipos de ajustes de operación.
275
+ def consultar_tipos_ajustes_operacion
276
+ body = { auth_request: auth }
277
+ r = raw_request(:consultar_tipos_ajustes_operacion, body)
278
+ ret = get_return_value(r, :consultar_tipos_ajustes_operacion)
279
+ parse_response_errores! ret
280
+ wrap_codigo_descripcion(ret[:array_codigo_descripcion])
281
+ end
282
+
283
+ private
284
+
285
+ def raw_request(action, body)
286
+ # Savon normalizes WSDL operation names to snake_case (e.g. consultarTiposRetenciones -> :consultar_tipos_retenciones).
287
+ # "aceptarFECred" becomes :aceptar_fe_cred (not :aceptar_f_e_cred).
288
+ savon_action = action == :aceptar_f_e_cred ? :aceptar_fe_cred : action
289
+ message = if body.nil?
290
+ nil
291
+ else
292
+ body.key?(:auth_request) ? body : { auth_request: auth }.merge(body)
293
+ end
294
+ resp = @client.request(savon_action, message)
295
+ body_hash = response_body(resp)
296
+ raise ServerError, "Unexpected response structure" if body_hash.nil? || !body_hash.is_a?(Hash)
297
+
298
+ body_hash
299
+ end
300
+
301
+ def auth
302
+ @wsaa.auth.merge(cuit_representada: cuit)
303
+ end
304
+
305
+ # Response envelope key may be "dummyResponse" or :dummy_response depending on Savon.
306
+ def response_body(resp)
307
+ h = resp.respond_to?(:to_hash) ? resp.to_hash : resp
308
+ key = h.keys.find { |k| k.to_s =~ /Response$/i }
309
+ key ? h[key] : h
310
+ end
311
+
312
+ # Return value may be under operacionFECredReturn, consultarCtasCtesReturn, etc.
313
+ def get_return_value(response_hash, _action)
314
+ if response_hash.nil?
315
+ response_hash
316
+ else
317
+ key = response_hash.keys.find { |k| k.to_s =~ /return$/i }
318
+ key ? (response_hash[key] || response_hash) : response_hash
319
+ end
320
+ end
321
+
322
+ def build_id_cta_cte(id_cta_cte)
323
+ if id_cta_cte.is_a?(Hash) && (id_cta_cte.key?(:cod_cta_cte) || id_cta_cte.key?(:id_factura))
324
+ id_cta_cte
325
+ else
326
+ raise ArgumentError,
327
+ "id_cta_cte must be { cod_cta_cte: long } or { id_factura: { cuit_emisor, cod_tipo_cmp, pto_vta, nro_cmp } }"
328
+ end
329
+ end
330
+
331
+ def build_id_comprobante(id_comprobante)
332
+ raise ArgumentError, "id_comprobante must be a Hash" unless id_comprobante.is_a?(Hash)
333
+
334
+ required = %i[cuit_emisor cod_tipo_cmp pto_vta nro_cmp]
335
+ missing = required.reject { |k| id_comprobante.key?(k) }
336
+ raise ArgumentError, "id_comprobante must include #{required.join(', ')}" if missing.any?
337
+
338
+ id_comprobante.slice(*required)
339
+ end
340
+
341
+ def opciones_to_aceptar(opciones)
342
+ out = {}
343
+ out[:array_confirmar_notas_d_c] =
344
+ { confirmar_nota: opciones[:array_confirmar_notas_dc] } if opciones[:array_confirmar_notas_dc]
345
+ out[:array_formas_cancelacion] =
346
+ { codigo_descripcion: opciones[:array_formas_cancelacion] } if opciones[:array_formas_cancelacion]
347
+ out[:array_retenciones] = { retencion: opciones[:array_retenciones] } if opciones[:array_retenciones]
348
+ out[:array_ajustes_operacion] =
349
+ { ajuste: opciones[:array_ajustes_operacion] } if opciones[:array_ajustes_operacion]
350
+ out[:tipo_cancelacion] = opciones[:tipo_cancelacion] if opciones[:tipo_cancelacion]
351
+ out[:importe_cancelado] = opciones[:importe_cancelado] if opciones[:importe_cancelado]
352
+ out[:importe_total_ret_pesos] = opciones[:importe_total_ret_pesos] if opciones[:importe_total_ret_pesos]
353
+ out[:importe_embargo_pesos] = opciones[:importe_embargo_pesos] if opciones[:importe_embargo_pesos]
354
+ out[:saldo_aceptado] = opciones[:saldo_aceptado] if opciones.key?(:saldo_aceptado)
355
+ out[:cod_moneda] = opciones[:cod_moneda] if opciones[:cod_moneda]
356
+ out[:cotizacion_moneda_ult] = opciones[:cotizacion_moneda_ult] if opciones.key?(:cotizacion_moneda_ult)
357
+ out[:informa_cbu] = opciones[:informa_cbu] if opciones.key?(:informa_cbu)
358
+ out[:cbu_comprador] = opciones[:cbu_comprador] if opciones[:cbu_comprador]
359
+ out
360
+ end
361
+
362
+ def normalize_cuit(value)
363
+ if value.nil? || value == ""
364
+ 0
365
+ else
366
+ value.to_s.gsub(/\D/, "").to_i
367
+ end
368
+ end
369
+
370
+ def format_date(value)
371
+ if value.nil?
372
+ value
373
+ else
374
+ value.is_a?(String) ? value : value.to_date.strftime("%Y-%m-%d")
375
+ end
376
+ end
377
+
378
+ def parse_operacion_response(r, action)
379
+ return_key = :"#{action}_return"
380
+ key = r.key?(return_key) ? return_key : r.keys.find { |k| k.to_s.include?("return") }
381
+ ret = key ? r[key] : r
382
+ parse_response_errores! ret.is_a?(Hash) ? ret : { key => ret }
383
+ ret = r[key] || r
384
+ {
385
+ resultado: ret[:resultado],
386
+ id_cta_cte: ret[:id_cta_cte],
387
+ evento: ret[:evento],
388
+ array_observaciones: wrap_codigo_descripcion(ret[:array_observaciones]),
389
+ array_errores: wrap_codigo_descripcion(ret[:array_errores])
390
+ }
391
+ end
392
+
393
+ def parse_response_errores!(h)
394
+ return if h.nil? || !h.is_a?(Hash)
395
+
396
+ errs = wrap_codigo_descripcion(h[:array_errores])
397
+ raise ResponseError, errs.map { |e| error_to_code_msg(e, :codigo, :descripcion) } if errs && errs.any?
398
+
399
+ format_errs = wrap_codigo_descripcion_string(h[:array_errores_formato])
400
+ if format_errs && format_errs.any?
401
+ raise ResponseError, format_errs.map { |e| error_to_code_msg(e, :codigo, :descripcion) }
402
+ end
403
+ end
404
+
405
+ def error_to_code_msg(e, code_key, msg_key)
406
+ code = e[code_key] || e[:codigo_descripcion]
407
+ msg = e[msg_key] || e[:descripcion] || ""
408
+ { code: code.to_s, msg: msg.to_s }
409
+ end
410
+
411
+ def wrap_array(val)
412
+ if val.nil?
413
+ []
414
+ else
415
+ val = val.values.first if val.is_a?(Hash) && val.size == 1
416
+ Array.wrap(val)
417
+ end
418
+ end
419
+
420
+ def wrap_codigo_descripcion(val)
421
+ if val.nil?
422
+ []
423
+ else
424
+ arr = val.is_a?(Hash) ? (val[:codigo_descripcion] || val[:codigoDescripcion]) : val
425
+ Array.wrap(arr)
426
+ end
427
+ end
428
+
429
+ def wrap_codigo_descripcion_string(val)
430
+ if val.nil?
431
+ []
432
+ else
433
+ arr = val.is_a?(Hash) ? (val[:codigo_descripcion_string] || val[:codigoDescripcionString]) : val
434
+ Array.wrap(arr)
435
+ end
436
+ end
437
+ end
438
+ end