diff --git a/Gemfile.lock b/Gemfile.lock
index a0391f4..e049105 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -119,8 +119,7 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
- rexml (3.3.1)
- strscan
+ rexml (3.3.9)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
@@ -144,7 +143,6 @@ GEM
nori (~> 2.4)
wasabi (>= 3.7, < 6)
shellany (0.0.1)
- strscan (3.1.0)
thor (1.3.1)
timeout (0.4.1)
tzinfo (2.0.6)
diff --git a/README.md b/README.md
index d5f91c2..9fa38b2 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Ruby client para los web services de la AFIP.
- wsaa (WSAA)
- wsfe (WSFE)
+- wsfex (WSFEX)
- ws_sr_constancia_inscripcion (WSConstanciaInscripcion)
- ws_sr_padron_a100 (PersonaServiceA100)
- ws_sr_padron_a4 (PersonaServiceA4)
@@ -32,6 +33,68 @@ ws = Afipws::WSFE.new env: :development, cuit: '...', key: File.read('test.key')
puts ws.cotizacion 'DOL'
```
+Para comprobantes de exportación (`Factura E`) se puede usar `Afipws::WSFEX`. Internamente autentica con `WSAA` usando `service: 'wsfex'`.
+
+```ruby
+require 'afipws'
+
+ws = Afipws::WSFEX.new env: :development, cuit: '...', key: File.read('test.key'), cert: File.read('test.crt')
+ultimo = ws.ultimo_comprobante_autorizado(pto_vta: 3, cbte_tipo: 19)
+
+rta = ws.autorizar_comprobantes(
+ pto_vta: 3,
+ cbte_tipo: 19,
+ comprobantes: [
+ {
+ cbte_nro: ultimo + 1,
+ fecha_cbte: Date.today,
+ tipo_expo: 2,
+ permiso_existente: 'N',
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ cuit_pais_cliente: 55_555_555_555,
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 121.0,
+ idioma_cbte: 1,
+ items: [
+ { pro_ds: 'Servicio mensual', pro_umed: 7, pro_total_item: 121.0 }
+ ]
+ }
+ ]
+)
+```
+
+`WSFEX` expone actualmente estos métodos:
+
+- `ultimo_comprobante_autorizado(opciones)`
+- `autorizar_comprobantes(opciones)`
+
+Para `autorizar_comprobantes`, el payload mínimo sigue la estructura documentada por AFIP para `FEXAuthorize`. A nivel comprobante se validan como obligatorios:
+
+- `id` (se completa automáticamente con `cbte_nro` si no se envía)
+- `cbte_tipo`
+- `punto_vta`
+- `cbte_nro`
+- `tipo_expo`
+- `permiso_existente` (requerido sólo para `cbte_tipo=19` y `tipo_expo=1`; vacío en otros casos)
+- `dst_cmp`
+- `cliente`
+- `domicilio_cliente`
+- `moneda_id`
+- `moneda_ctz` (no requerido si `can_mis_mon_ext` es `'S'`; debe ser mayor a 0)
+- `imp_total`
+- `idioma_cbte`
+- `items`
+- `cuit_pais_cliente` o `id_impositivo`
+
+Cada item requiere al menos:
+
+- `pro_ds`
+- `pro_umed`
+- `pro_total_item`
+
Ver specs para más ejemplos.
## Contributing
diff --git a/lib/afipws.rb b/lib/afipws.rb
index 4d7f0cf..d6f70bf 100644
--- a/lib/afipws.rb
+++ b/lib/afipws.rb
@@ -19,6 +19,7 @@ module Afipws
require 'afipws/client'
require 'afipws/wsaa'
require 'afipws/wsfe'
+require 'afipws/wsfex'
require 'afipws/ws_constancia_inscripcion'
require 'afipws/persona_service_a4'
require 'afipws/persona_service_a5'
diff --git a/lib/afipws/wsfe.rb b/lib/afipws/wsfe.rb
index 3c4babd..51bf377 100644
--- a/lib/afipws/wsfe.rb
+++ b/lib/afipws/wsfe.rb
@@ -70,7 +70,7 @@ def autorizar_comprobantes opciones
'FeCAEReq' => {
'FeCabReq' => opciones.select_keys(:cbte_tipo, :pto_vta).merge(cant_reg: comprobantes.size),
'FeDetReq' => {
- 'FECAEDetRequest' => comprobantes.map { |comprobante| comprobante_to_request comprobante }
+ 'FECAEDetRequest' => comprobantes.map { |comprobante| comprobante_to_request(comprobante, opciones) }
}
}
}
@@ -83,11 +83,14 @@ def autorizar_comprobantes opciones
x2r r, cae_fch_vto: :date, fch_serv_desde: :date, fch_serv_hasta: :date, fch_vto_pago: :date, cbte_nro: :integer, code: :integer
end
- def comprobante_to_request comprobante
+ def comprobante_to_request(comprobante, opciones)
nro = comprobante.delete :cbte_nro
iva = comprobante.delete :imp_iva
comprobante.delete :tributos if comprobante[:imp_trib] == 0
- comprobante.merge cbte_desde: nro, cbte_hasta: nro, 'ImpIVA' => iva
+ comprobante.merge! cbte_desde: nro, cbte_hasta: nro, 'ImpIVA' => iva
+ comprobante.merge! periodo_asoc: opciones[:periodo_asoc] if opciones[:periodo_asoc]
+
+ comprobante
end
def solicitar_caea
@@ -111,7 +114,7 @@ def informar_comprobantes_caea opciones
'FeCabReq' => opciones.select_keys(:cbte_tipo, :pto_vta).merge(cant_reg: comprobantes.size),
'FeDetReq' => {
'FECAEADetRequest' => comprobantes.map do |comprobante|
- comprobante_to_request comprobante.merge('CAEA' => comprobante.delete(:caea))
+ comprobante_to_request(comprobante.merge('CAEA' => comprobante.delete(:caea)), opciones)
end
}
}
diff --git a/lib/afipws/wsfex.rb b/lib/afipws/wsfex.rb
new file mode 100644
index 0000000..ec4ca50
--- /dev/null
+++ b/lib/afipws/wsfex.rb
@@ -0,0 +1,348 @@
+module Afipws
+ class WSFEX
+ include TypeConversions
+
+ attr_reader :wsaa, :cuit
+
+ WSDL = {
+ development: 'https://wswhomo.afip.gov.ar/wsfexv1/service.asmx?WSDL',
+ production: 'https://servicios1.afip.gov.ar/wsfexv1/service.asmx?WSDL',
+ test: Root + '/spec/fixtures/wsfex/wsfex.wsdl'
+ }.freeze
+
+ REQUIRED_COMPROBANTE_FIELDS = %i[
+ id cbte_tipo punto_vta cbte_nro tipo_expo dst_cmp cliente
+ domicilio_cliente moneda_id imp_total idioma_cbte items
+ ].freeze
+ REQUIRED_ITEM_FIELDS = %i[pro_ds pro_umed pro_total_item].freeze
+
+ # Tipos de comprobante (manual WSFEX v3.1.1, pág. 19)
+ FACTURA_E = 19
+ NOTA_DEBITO_E = 20
+ NOTA_CREDITO_E = 21
+ NOTAS_DE_AJUSTE_E = [NOTA_DEBITO_E, NOTA_CREDITO_E].freeze
+
+ # Tipos de exportación (pág. 13): 1=Bienes, 2=Servicios, 4=Otros
+ TIPOS_EXPO_NO_BIENES = [2, 4].freeze
+
+ # Remitos sin tope de cantidad como cmps_asoc (pág. 19)
+ REMITOS_SIN_TOPE = [88, 89, 91, 993, 994].freeze
+
+ # Unidades de medida que indican intangibles/servicios sin pro_qty (pág. 21)
+ UMEDS_SIN_CANTIDAD = [0, 97, 99].freeze
+
+ # Tope máximo de ítems por comprobante (pág. 21, código 1781)
+ MAX_ITEMS_POR_COMPROBANTE = 9999
+
+ def initialize options = {}
+ @cuit = options[:cuit]
+ @wsaa = WSAA.new options.merge(service: 'wsfex')
+ @client = Client.new Hash(options[:savon]).reverse_merge(wsdl: WSDL[@wsaa.env])
+ end
+
+ def ultimo_comprobante_autorizado opciones
+ auth_block = auth.merge('Pto_venta' => opciones[:pto_vta], 'Cbte_Tipo' => opciones[:cbte_tipo])
+ response = request(:fex_get_last_cmp, { 'Auth' => auth_block })
+ x2r(response[:fex_result_last_cmp] || {}, cbte_nro: :integer, cbte_fecha: :date)
+ end
+
+ def autorizar_comprobantes opciones
+ Array.wrap(opciones[:comprobantes]).map do |comprobante|
+ response = request(:fex_authorize, { 'Auth' => auth, 'Cmp' => comprobante_to_request(comprobante, opciones) }, raise_on_errors: false)
+ result = x2r(response[:fex_result_auth] || {}, id: :integer, cuit: :integer, cbte_tipo: :integer, punto_vta: :integer,
+ cbte_nro: :integer, fch_venc_cae: :date, fch_cbte: :date)
+ errores = normalize_messages(response[:fex_err], :err_code, :err_msg)
+ eventos = normalize_messages(response[:fex_events], :event_code, :event_msg)
+
+ {
+ resultado: result[:resultado],
+ cae: result[:cae],
+ cae_fch_vto: result[:fch_venc_cae],
+ cbte_nro: result[:cbte_nro],
+ reproceso: result[:reproceso] == 'S',
+ observaciones: result[:motivos_obs].presence,
+ errores: errores,
+ eventos: eventos
+ }
+ end
+ end
+
+ private
+
+ def auth
+ wsaa.auth.merge(cuit: cuit).transform_keys { |key| soap_key(key) }
+ end
+
+ def comprobante_to_request comprobante, opciones
+ comprobante = normalize_comprobante(comprobante, opciones)
+ validate_comprobante!(comprobante)
+
+ request_payload(
+ r2x(comprobante.compact, fecha_cbte: :date, fecha_pago: :date, fecha_permiso_existente: :date)
+ )
+ end
+
+ def normalize_comprobante comprobante, opciones
+ comprobante.deep_dup.tap do |payload|
+ payload[:cbte_tipo] ||= opciones[:cbte_tipo]
+ payload[:punto_vta] ||= opciones[:pto_vta]
+ payload[:id] ||= payload[:cbte_nro]
+ payload[:fecha_cbte] ||= payload.delete(:cbte_fch)
+ payload[:fecha_pago] ||= payload.delete(:fch_vto_pago)
+ payload[:items] = normalize_collection(payload[:items], :item)
+ payload[:permisos] = normalize_collection(payload[:permisos], :permiso)
+ payload[:cmps_asoc] = normalize_collection(payload[:cmps_asoc], :cmp_asoc)
+ payload[:opcionales] = normalize_collection(payload[:opcionales], :opcional)
+ payload[:actividades] = normalize_collection(payload[:actividades], :actividad)
+ end
+ end
+
+ def request_payload comprobante
+ payload = {}
+ payload['Id'] = comprobante[:id]
+ payload['Fecha_cbte'] = comprobante[:fecha_cbte]
+ payload['Cbte_Tipo'] = comprobante[:cbte_tipo]
+ payload['Punto_vta'] = comprobante[:punto_vta]
+ payload['Cbte_nro'] = comprobante[:cbte_nro]
+ payload['Tipo_expo'] = comprobante[:tipo_expo]
+ payload['Permiso_existente'] = comprobante[:permiso_existente] || ''
+ payload['Permisos'] = request_collection(comprobante[:permisos], 'Permiso') if comprobante[:permisos].present?
+ payload['Dst_cmp'] = comprobante[:dst_cmp]
+ payload['Cliente'] = comprobante[:cliente]
+ payload['Cuit_pais_cliente'] = comprobante[:cuit_pais_cliente]
+ payload['Domicilio_cliente'] = comprobante[:domicilio_cliente]
+ payload['Id_impositivo'] = comprobante[:id_impositivo]
+ payload['Moneda_Id'] = comprobante[:moneda_id]
+ payload['Moneda_ctz'] = comprobante[:moneda_ctz]
+ payload['CanMisMonExt'] = comprobante[:can_mis_mon_ext]
+ payload['Obs_comerciales'] = comprobante[:obs_comerciales]
+ payload['Imp_total'] = comprobante[:imp_total]
+ payload['Obs'] = comprobante[:obs]
+ payload['Cmps_asoc'] = request_collection(comprobante[:cmps_asoc], 'Cmp_asoc') if comprobante[:cmps_asoc].present?
+ payload['Forma_pago'] = comprobante[:forma_pago]
+ payload['Incoterms'] = comprobante[:incoterms]
+ payload['Incoterms_Ds'] = comprobante[:incoterms_ds]
+ payload['Idioma_cbte'] = comprobante[:idioma_cbte]
+ payload['Items'] = request_collection(comprobante[:items], 'Item') if comprobante[:items].present?
+ payload['Opcionales'] = request_collection(comprobante[:opcionales], 'Opcional') if comprobante[:opcionales].present?
+ payload['Fecha_pago'] = comprobante[:fecha_pago]
+ payload['Actividades'] = request_collection(comprobante[:actividades], 'Actividad') if comprobante[:actividades].present?
+ payload.compact
+ end
+
+ def request_collection collection, item_name
+ item = Array.wrap(collection.values.first).map { |entry| request_hash(entry) }
+ { item_name => item }
+ end
+
+ def request_hash hash
+ Hash[hash.map { |key, value| [soap_key(key), value] }]
+ end
+
+ def soap_key key
+ key.to_s.sub(/\A./, &:upcase)
+ end
+
+ def normalize_collection collection, item_key
+ case collection
+ when Array then { item_key => collection }
+ when Hash then collection
+ else collection
+ end
+ end
+
+ def validate_comprobante! comprobante
+ missing_fields = REQUIRED_COMPROBANTE_FIELDS.select { |field| comprobante[field].blank? }
+ raise ArgumentError, "faltan campos obligatorios para WSFEX: #{missing_fields.join(', ')}" if missing_fields.present?
+ raise ArgumentError, 'WSFEX requiere cuit_pais_cliente o id_impositivo' if comprobante[:cuit_pais_cliente].blank? && comprobante[:id_impositivo].blank?
+
+ validate_permiso_rules!(comprobante)
+ validate_currency_rules!(comprobante)
+ validate_payment_rules!(comprobante)
+ validate_associated_vouchers!(comprobante)
+ validate_items!(Array.wrap(comprobante.dig(:items, :item)))
+ end
+
+ def validate_permiso_rules!(comprobante)
+ cbte_tipo = comprobante[:cbte_tipo].to_i
+ tipo_expo = comprobante[:tipo_expo].to_i
+ permiso_existente = comprobante[:permiso_existente]
+ permisos = Array.wrap(comprobante.dig(:permisos, :permiso))
+
+ if permiso_existente.present? && !%w[S N].include?(permiso_existente)
+ raise ArgumentError, 'WSFEX requiere que permiso_existente sea S, N o vacío'
+ end
+
+ if TIPOS_EXPO_NO_BIENES.include?(tipo_expo)
+ raise ArgumentError, 'WSFEX requiere permiso_existente vacío para tipo_expo 2 o 4' if permiso_existente.present?
+ raise ArgumentError, 'WSFEX no permite permisos para tipo_expo 2 o 4' if permisos.present?
+ return
+ end
+
+ if cbte_tipo == FACTURA_E
+ raise ArgumentError, 'WSFEX requiere permiso_existente para cbte_tipo 19 y tipo_expo 1' if permiso_existente.blank?
+ raise ArgumentError, 'WSFEX requiere permisos cuando permiso_existente es S' if permiso_existente == 'S' && permisos.blank?
+ raise ArgumentError, 'WSFEX no permite permisos cuando permiso_existente es N' if permiso_existente == 'N' && permisos.present?
+ else
+ raise ArgumentError, 'WSFEX requiere permiso_existente vacío para cbte_tipo 20 o 21' if permiso_existente.present?
+ end
+
+ permisos.each_with_index do |permiso, index|
+ if permiso[:id_permiso].blank? || permiso[:dst_merc].blank?
+ raise ArgumentError, "WSFEX requiere id_permiso y dst_merc en permiso #{index + 1}"
+ end
+ end
+ end
+
+ def validate_currency_rules! comprobante
+ cbte_tipo = comprobante[:cbte_tipo].to_i
+ moneda_id = comprobante[:moneda_id].to_s
+ moneda_ctz = comprobante[:moneda_ctz]
+ can_mis_mon_ext = comprobante[:can_mis_mon_ext]
+
+ if can_mis_mon_ext.present? && !%w[S N].include?(can_mis_mon_ext)
+ raise ArgumentError, 'WSFEX requiere que can_mis_mon_ext sea S o N'
+ end
+
+ if can_mis_mon_ext.present? && (moneda_id == 'PES' || NOTAS_DE_AJUSTE_E.include?(cbte_tipo))
+ raise ArgumentError, 'WSFEX no permite can_mis_mon_ext para moneda PES o cbte_tipo 20/21'
+ end
+
+ numeric_moneda_ctz = numeric_value(moneda_ctz)
+ if can_mis_mon_ext != 'S'
+ raise ArgumentError, 'WSFEX requiere moneda_ctz si can_mis_mon_ext no es S' if moneda_ctz.blank?
+ end
+
+ if moneda_ctz.present? && (numeric_moneda_ctz.nil? || numeric_moneda_ctz <= 0)
+ raise ArgumentError, 'WSFEX requiere que moneda_ctz sea mayor a 0'
+ end
+
+ if moneda_id == 'PES' && moneda_ctz.present? && numeric_moneda_ctz != 1.0
+ raise ArgumentError, 'WSFEX requiere moneda_ctz igual a 1 cuando moneda_id es PES'
+ end
+ end
+
+ def validate_payment_rules! comprobante
+ cbte_tipo = comprobante[:cbte_tipo].to_i
+ tipo_expo = comprobante[:tipo_expo].to_i
+ fecha_pago = comprobante[:fecha_pago]
+
+ raise ArgumentError, 'WSFEX requiere forma_pago para cbte_tipo 19' if cbte_tipo == FACTURA_E && comprobante[:forma_pago].blank?
+ if cbte_tipo == FACTURA_E && tipo_expo == 1 && comprobante[:incoterms].blank?
+ raise ArgumentError, 'WSFEX requiere incoterms para cbte_tipo 19 y tipo_expo 1'
+ end
+ if comprobante[:incoterms_ds].present? && comprobante[:incoterms].blank?
+ raise ArgumentError, 'WSFEX requiere incoterms cuando se informa incoterms_ds'
+ end
+
+ if fecha_pago.present? && parse_ws_date(fecha_pago).nil?
+ raise ArgumentError, 'WSFEX requiere que fecha_pago tenga formato YYYYMMDD o sea Date'
+ end
+
+ if cbte_tipo == FACTURA_E && TIPOS_EXPO_NO_BIENES.include?(tipo_expo)
+ raise ArgumentError, 'WSFEX requiere fecha_pago para cbte_tipo 19 y tipo_expo 2 o 4' if fecha_pago.blank?
+
+ fecha_cbte = parse_ws_date(comprobante[:fecha_cbte])
+ fecha_pago_value = parse_ws_date(fecha_pago)
+ if fecha_cbte.present? && fecha_pago_value.present? && fecha_pago_value < fecha_cbte
+ raise ArgumentError, 'WSFEX requiere que fecha_pago sea igual o posterior a fecha_cbte'
+ end
+ elsif cbte_tipo != FACTURA_E && fecha_pago.present?
+ raise ArgumentError, 'WSFEX no permite fecha_pago cuando cbte_tipo no es 19'
+ end
+ end
+
+ def validate_associated_vouchers! comprobante
+ cbte_tipo = comprobante[:cbte_tipo].to_i
+ tipo_expo = comprobante[:tipo_expo].to_i
+ cmps_asoc = Array.wrap(comprobante.dig(:cmps_asoc, :cmp_asoc))
+
+ if NOTAS_DE_AJUSTE_E.include?(cbte_tipo) && tipo_expo == 2 && cmps_asoc.blank?
+ raise ArgumentError, 'WSFEX requiere cmps_asoc para notas de servicio'
+ end
+
+ return if cmps_asoc.blank?
+
+ if cbte_tipo == FACTURA_E
+ if cmps_asoc.any? { |cmp_asoc| !REMITOS_SIN_TOPE.include?(cmp_asoc[:cbte_tipo].to_i) }
+ raise ArgumentError, 'WSFEX solo permite remitos de tabaco como cmps_asoc para cbte_tipo 19'
+ end
+ elsif !NOTAS_DE_AJUSTE_E.include?(cbte_tipo)
+ raise ArgumentError, 'WSFEX solo permite cmps_asoc para cbte_tipo 19, 20 o 21'
+ end
+
+ if cmps_asoc.size > 1 && cmps_asoc.any? { |cmp_asoc| !REMITOS_SIN_TOPE.include?(cmp_asoc[:cbte_tipo].to_i) }
+ raise ArgumentError, 'WSFEX solo permite más de un cmps_asoc cuando todos son remitos tipo 88, 89, 91, 993 o 994'
+ end
+ end
+
+ def validate_items! items
+ raise ArgumentError, 'WSFEX requiere al menos un item' if items.blank?
+ raise ArgumentError, "WSFEX permite hasta #{MAX_ITEMS_POR_COMPROBANTE} items" if items.size > MAX_ITEMS_POR_COMPROBANTE
+
+ items.each_with_index do |item, index|
+ missing_item_fields = REQUIRED_ITEM_FIELDS.select { |field| item[field].blank? }
+ if missing_item_fields.present?
+ raise ArgumentError, "faltan campos obligatorios para WSFEX en item #{index + 1}: #{missing_item_fields.join(', ')}"
+ end
+
+ pro_umed = item[:pro_umed].to_i
+ if !UMEDS_SIN_CANTIDAD.include?(pro_umed)
+ raise ArgumentError, "WSFEX requiere pro_qty en item #{index + 1}" if item[:pro_qty].blank?
+ raise ArgumentError, "WSFEX requiere pro_precio_uni en item #{index + 1}" if item[:pro_precio_uni].blank?
+ else
+ %i[pro_qty pro_precio_uni pro_bonificacion].each do |field|
+ next if item[field].blank?
+
+ value = numeric_value(item[field])
+ if value.nil? || value != 0
+ raise ArgumentError, "WSFEX requiere #{field} igual a 0 o no informado cuando pro_umed es 0, 97 o 99 en item #{index + 1}"
+ end
+ end
+ end
+ end
+ end
+
+ def parse_ws_date value
+ return value if value.is_a?(Date)
+ return nil if value.blank?
+
+ Date.strptime(value.to_s, '%Y%m%d')
+ rescue ArgumentError
+ nil
+ end
+
+ def numeric_value value
+ Float(value)
+ rescue ArgumentError, TypeError
+ nil
+ end
+
+ def normalize_messages raw_messages, code_key, msg_key
+ messages = if raw_messages.is_a?(Hash)
+ raw_messages.values.find { |value| value.is_a?(Array) || value.is_a?(Hash) } || raw_messages
+ else
+ raw_messages
+ end
+
+ Array.wrap(messages).filter_map do |message|
+ next unless message.is_a?(Hash)
+
+ code = message[code_key] || message[:code]
+ msg = message[msg_key] || message[:msg]
+ next if code.to_i.zero? && msg.to_s.strip.empty?
+ next if code.to_i.zero? && msg.to_s.strip.casecmp('ok').zero?
+
+ x2r({ code: code, msg: msg }, code: :integer)
+ end
+ end
+
+ def request action, body = nil, raise_on_errors: true
+ response = @client.request(action, body).to_hash[:"#{action}_response"][:"#{action}_result"]
+ errors = normalize_messages(response[:fex_err], :err_code, :err_msg)
+ raise ResponseError, errors if raise_on_errors && errors.present?
+
+ response
+ end
+ end
+end
diff --git a/spec/afipws/wsfe_spec.rb b/spec/afipws/wsfe_spec.rb
index f3ca03d..0848527 100644
--- a/spec/afipws/wsfe_spec.rb
+++ b/spec/afipws/wsfe_spec.rb
@@ -333,12 +333,12 @@ def fecha_inicio_quincena_siguiente fecha
end
context 'comprobante_to_request' do
- def c2r comprobante
- subject.comprobante_to_request comprobante
+ def c2r comprobante, opciones
+ subject.comprobante_to_request comprobante, opciones
end
it 'no debería enviar tag tributos si el impTrib es 0' do
- c2r(imp_trib: 0.0, tributos: { tributo: [] }).should_not have_key :tributos
+ c2r({imp_trib: 0.0, tributos: { tributo: [] }},{}).should_not have_key :tributos
end
end
end
diff --git a/spec/afipws/wsfex_spec.rb b/spec/afipws/wsfex_spec.rb
new file mode 100644
index 0000000..a6f27fc
--- /dev/null
+++ b/spec/afipws/wsfex_spec.rb
@@ -0,0 +1,298 @@
+require 'spec_helper'
+
+module Afipws
+ describe WSFEX do
+ let(:ta) { {token: 't', sign: 's'} }
+ let(:ws) { WSFEX.new(cuit: '1').tap { |service| service.wsaa.stubs auth: ta } }
+
+ context 'métodos de negocio' do
+ it 'ultimo_comprobante_autorizado' do
+ savon.expects(:fex_get_last_cmp).with(message: has_path(
+ '//Auth/Token' => 't',
+ '//Auth/Pto_venta' => 3,
+ '//Auth/Cbte_Tipo' => 19
+ )).returns(fixture('wsfex/fex_get_last_cmp/success'))
+
+ ws.ultimo_comprobante_autorizado(pto_vta: 3, cbte_tipo: 19).should == {
+ cbte_nro: 20,
+ cbte_fecha: Date.new(2026, 4, 10)
+ }
+ end
+
+ it 'autorizar_comprobantes' do
+ savon.expects(:fex_authorize).with(message: has_path(
+ '//Auth/Token' => 't',
+ '//Cmp/Id' => 44,
+ '//Cmp/Cbte_Tipo' => 19,
+ '//Cmp/Punto_vta' => 3,
+ '//Cmp/Cbte_nro' => 44,
+ '//Cmp/Fecha_cbte' => '20260414',
+ '//Cmp/Tipo_expo' => 2,
+ '//Cmp/Dst_cmp' => 200,
+ '//Cmp/Cliente' => 'Cliente del exterior',
+ '//Cmp/Cuit_pais_cliente' => 55_555_555_555,
+ '//Cmp/Domicilio_cliente' => 'Rua Falsa 123',
+ '//Cmp/Moneda_Id' => 'DOL',
+ '//Cmp/Moneda_ctz' => 1,
+ '//Cmp/Imp_total' => 121,
+ '//Cmp/Forma_pago' => 'Transferencia',
+ '//Cmp/Idioma_cbte' => 1,
+ '//Cmp/Fecha_pago' => '20260420',
+ '//Cmp/Items/Item[1]/Pro_ds' => 'Servicio mensual',
+ '//Cmp/Items/Item[1]/Pro_qty' => 1,
+ '//Cmp/Items/Item[1]/Pro_umed' => 7,
+ '//Cmp/Items/Item[1]/Pro_precio_uni' => 121,
+ '//Cmp/Items/Item[1]/Pro_total_item' => 121
+ )).returns(fixture('wsfex/fex_authorize/autorizacion_1_cbte'))
+
+ ws.autorizar_comprobantes(
+ pto_vta: 3,
+ cbte_tipo: 19,
+ comprobantes: [{
+ id: 44,
+ cbte_nro: 44,
+ fecha_cbte: Date.new(2026, 4, 14),
+ tipo_expo: 2,
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ cuit_pais_cliente: 55_555_555_555,
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 121,
+ forma_pago: 'Transferencia',
+ idioma_cbte: 1,
+ fecha_pago: Date.new(2026, 4, 20),
+ items: [{ pro_ds: 'Servicio mensual', pro_qty: 1, pro_umed: 7, pro_precio_uni: 121, pro_total_item: 121 }]
+ }]
+ ).should == [
+ {
+ resultado: 'A',
+ cae: '12345678901234',
+ cae_fch_vto: Date.new(2026, 5, 1),
+ cbte_nro: 44,
+ reproceso: false,
+ observaciones: nil,
+ errores: [],
+ eventos: []
+ }
+ ]
+ end
+
+ it 'autorizar_comprobantes con observaciones' do
+ savon.expects(:fex_authorize).with(message: :any).returns(fixture('wsfex/fex_authorize/una_observacion'))
+
+ ws.autorizar_comprobantes(
+ cbte_tipo: 19,
+ pto_vta: 3,
+ comprobantes: [{
+ id: 45,
+ cbte_nro: 45,
+ cbte_fch: Date.new(2026, 4, 14),
+ tipo_expo: 2,
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ id_impositivo: 'X123456',
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 0,
+ forma_pago: 'Transferencia',
+ idioma_cbte: 1,
+ fecha_pago: Date.new(2026, 4, 20),
+ items: [{ pro_ds: 'Servicio mensual', pro_qty: 1, pro_umed: 7, pro_precio_uni: 0, pro_total_item: 0 }]
+ }]
+ ).should == [
+ {
+ resultado: 'R',
+ cae: nil,
+ cae_fch_vto: nil,
+ cbte_nro: 45,
+ reproceso: false,
+ observaciones: 'Observacion',
+ errores: [],
+ eventos: [{ code: 2001, msg: 'Evento programado' }]
+ }
+ ]
+ end
+
+ it 'autorizar_comprobantes con error devuelve el error sin levantar excepción' do
+ savon.expects(:fex_authorize).with(message: :any).returns(fixture('wsfex/fex_authorize/con_error'))
+
+ ws.autorizar_comprobantes(
+ cbte_tipo: 19,
+ pto_vta: 3,
+ comprobantes: [{
+ id: 46,
+ cbte_nro: 46,
+ fecha_cbte: Date.new(2026, 4, 14),
+ tipo_expo: 2,
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ cuit_pais_cliente: 55_555_555_555,
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 121,
+ forma_pago: 'Transferencia',
+ idioma_cbte: 1,
+ fecha_pago: Date.new(2026, 4, 20),
+ items: [{ pro_ds: 'Servicio mensual', pro_qty: 1, pro_umed: 7, pro_precio_uni: 121, pro_total_item: 121 }]
+ }]
+ ).should == [
+ {
+ resultado: 'R',
+ cae: nil,
+ cae_fch_vto: nil,
+ cbte_nro: 46,
+ reproceso: false,
+ observaciones: nil,
+ errores: [{ code: 1570, msg: 'Cuit_pais_cliente invalido' }],
+ eventos: []
+ }
+ ]
+ end
+
+ it 'valida campos obligatorios del comprobante' do
+ -> { ws.autorizar_comprobantes(cbte_tipo: 19, pto_vta: 3, comprobantes: [{ cbte_nro: 44 }]) }.should raise_error(
+ ArgumentError,
+ /faltan campos obligatorios para WSFEX/
+ )
+ end
+
+ it 'usa cbte_nro como id cuando no se envía explícito' do
+ savon.expects(:fex_authorize).with(message: has_path(
+ '//Cmp/Id' => 44,
+ '//Cmp/Cbte_nro' => 44
+ )).returns(fixture('wsfex/fex_authorize/autorizacion_1_cbte'))
+
+ ws.autorizar_comprobantes(
+ pto_vta: 3,
+ cbte_tipo: 19,
+ comprobantes: [{
+ cbte_nro: 44,
+ fecha_cbte: Date.new(2026, 4, 14),
+ tipo_expo: 2,
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ cuit_pais_cliente: 55_555_555_555,
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 121,
+ forma_pago: 'Transferencia',
+ idioma_cbte: 1,
+ fecha_pago: Date.new(2026, 4, 20),
+ items: [{ pro_ds: 'Servicio mensual', pro_qty: 1, pro_umed: 7, pro_precio_uni: 121, pro_total_item: 121 }]
+ }]
+ )
+ end
+
+ it 'requiere fecha_pago para facturas de servicios' do
+ -> do
+ ws.autorizar_comprobantes(
+ cbte_tipo: 19,
+ pto_vta: 3,
+ comprobantes: [{
+ id: 47,
+ cbte_nro: 47,
+ fecha_cbte: Date.new(2026, 4, 14),
+ tipo_expo: 2,
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ id_impositivo: 'X123456',
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 121,
+ forma_pago: 'Transferencia',
+ idioma_cbte: 1,
+ items: [{ pro_ds: 'Servicio mensual', pro_umed: 7, pro_qty: 1, pro_precio_uni: 121, pro_total_item: 121 }]
+ }]
+ )
+ end.should raise_error(ArgumentError, /fecha_pago/)
+ end
+
+ it 'rechaza más de un cmps_asoc cuando no son todos remitos 88, 89, 91, 993 o 994' do
+ -> do
+ ws.autorizar_comprobantes(
+ cbte_tipo: 20,
+ pto_vta: 3,
+ comprobantes: [{
+ id: 49,
+ cbte_nro: 49,
+ fecha_cbte: Date.new(2026, 4, 14),
+ tipo_expo: 1,
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ id_impositivo: 'X123456',
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 121,
+ forma_pago: 'Transferencia',
+ idioma_cbte: 1,
+ cmps_asoc: [
+ { cbte_tipo: 19, cbte_punto_vta: 3, cbte_nro: 1 },
+ { cbte_tipo: 19, cbte_punto_vta: 3, cbte_nro: 2 }
+ ],
+ items: [{ pro_ds: 'Producto', pro_umed: 7, pro_qty: 1, pro_precio_uni: 121, pro_total_item: 121 }]
+ }]
+ )
+ end.should raise_error(ArgumentError, /más de un cmps_asoc/)
+ end
+
+ it 'rechaza pro_qty distinto de 0 cuando pro_umed es 0, 97 o 99' do
+ -> do
+ ws.autorizar_comprobantes(
+ cbte_tipo: 19,
+ pto_vta: 3,
+ comprobantes: [{
+ id: 50,
+ cbte_nro: 50,
+ fecha_cbte: Date.new(2026, 4, 14),
+ tipo_expo: 2,
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ id_impositivo: 'X123456',
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 121,
+ forma_pago: 'Transferencia',
+ idioma_cbte: 1,
+ fecha_pago: Date.new(2026, 4, 20),
+ items: [{ pro_ds: 'Servicio', pro_umed: 99, pro_qty: 5, pro_precio_uni: 0, pro_total_item: 121 }]
+ }]
+ )
+ end.should raise_error(ArgumentError, /pro_qty igual a 0 o no informado/)
+ end
+
+ it 'requiere permiso_existente para facturas de productos' do
+ -> do
+ ws.autorizar_comprobantes(
+ cbte_tipo: 19,
+ pto_vta: 3,
+ comprobantes: [{
+ id: 48,
+ cbte_nro: 48,
+ fecha_cbte: Date.new(2026, 4, 14),
+ tipo_expo: 1,
+ dst_cmp: 200,
+ cliente: 'Cliente del exterior',
+ id_impositivo: 'X123456',
+ domicilio_cliente: 'Rua Falsa 123',
+ moneda_id: 'DOL',
+ moneda_ctz: 1,
+ imp_total: 121,
+ forma_pago: 'Transferencia',
+ incoterms: 'FOB',
+ idioma_cbte: 1,
+ items: [{ pro_ds: 'Producto', pro_umed: 7, pro_qty: 1, pro_precio_uni: 121, pro_total_item: 121 }]
+ }]
+ )
+ end.should raise_error(ArgumentError, /permiso_existente/)
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/wsfex/fex_authorize/autorizacion_1_cbte.xml b/spec/fixtures/wsfex/fex_authorize/autorizacion_1_cbte.xml
new file mode 100644
index 0000000..af7bf38
--- /dev/null
+++ b/spec/fixtures/wsfex/fex_authorize/autorizacion_1_cbte.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+ 44
+ 20111111112
+ 19
+ 3
+ 44
+ 12345678901234
+ 20260501
+ 20260414
+ A
+ N
+
+
+
+ 0
+ OK
+
+
+ 0
+
+
+
+
+
+
diff --git a/spec/fixtures/wsfex/fex_authorize/con_error.xml b/spec/fixtures/wsfex/fex_authorize/con_error.xml
new file mode 100644
index 0000000..6a15f1a
--- /dev/null
+++ b/spec/fixtures/wsfex/fex_authorize/con_error.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+ 46
+ 20111111112
+ 19
+ 3
+ 46
+ 20260414
+ R
+ N
+
+
+
+ 1570
+ Cuit_pais_cliente invalido
+
+
+ 0
+
+
+
+
+
+
diff --git a/spec/fixtures/wsfex/fex_authorize/una_observacion.xml b/spec/fixtures/wsfex/fex_authorize/una_observacion.xml
new file mode 100644
index 0000000..3671dfd
--- /dev/null
+++ b/spec/fixtures/wsfex/fex_authorize/una_observacion.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+ 45
+ 20111111112
+ 19
+ 3
+ 45
+ 20260414
+ R
+ N
+ Observacion
+
+
+ 0
+ OK
+
+
+ 2001
+ Evento programado
+
+
+
+
+
diff --git a/spec/fixtures/wsfex/fex_get_last_cmp/success.xml b/spec/fixtures/wsfex/fex_get_last_cmp/success.xml
new file mode 100644
index 0000000..c50fa09
--- /dev/null
+++ b/spec/fixtures/wsfex/fex_get_last_cmp/success.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+ 20
+ 20260410
+
+
+ 0
+ OK
+
+
+ 0
+
+
+
+
+
+
diff --git a/spec/fixtures/wsfex/wsfex.wsdl b/spec/fixtures/wsfex/wsfex.wsdl
new file mode 100644
index 0000000..5c8cdab
--- /dev/null
+++ b/spec/fixtures/wsfex/wsfex.wsdl
@@ -0,0 +1,1330 @@
+
+
+ Web Service orientado al servicio de autorizacion de comprobantes de Exportacion electronicos
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Autoriza un comprobante, devolviendo su CAE correspondiente
+
+
+
+
+ Recupera los datos completos de un comprobante ya autorizado
+
+
+
+
+ Recupera el listado de los tipos de comprobante y su codigo utilizables en servicio de autorizacion
+
+
+
+
+ Recupera el listado de los tipos de exportacion y sus codigo utilizables en servicio de autorizacion
+
+
+
+
+ Recupera el listado Incoterms utilizables en servicio de autorizacion
+
+
+
+
+ Recupera el listado de los idiomas y sus codigos utilizables en servicio de autorizacion
+
+
+
+
+ Recupera el listado de las unidades de medida y su codigo utilizables en servicio de autorizacion
+
+
+
+
+ Recupera el listado de paises
+
+
+
+
+ Recupera el listado de las cuits de los paises de destinacion
+
+
+
+
+ Recupera el listado de monedas y su codigo utilizables en servicio de autorizacion
+
+
+
+
+ Recupera el listado de monedas que tengan cotizacion de ADUANA a una fecha determinada, utilizables en el proceso de autorizacion de comprobantes de servicios
+
+
+
+
+ Recupera el ultimos comprobante autorizado
+
+
+
+
+ Metodo dummy para verificacion de funcionamiento
+
+
+
+
+ Recupera la cotizacion de la moneda consultada y su fecha
+
+
+
+
+ Recupera el ultimo ID y su fecha
+
+
+
+
+ Recupera el listado de los puntos de venta registrados para Factura electronica de exportacion - WS y su estado
+
+
+
+
+ Verifica la existencia de la permiso/pais de destinación de embarque ingresado
+
+
+
+
+ Recupera el listado de identificadores para los campos Opcionales
+
+
+
+
+ Recupera el listado de las diferentes actividades habilitadas para el emisor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Web Service orientado al servicio de autorizacion de comprobantes de Exportacion electronicos
+
+
+
+
+
+
+
+