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 + + + + + + + +