diff --git a/Gemfile b/Gemfile index ee41b3c..ee0d38e 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source "http://rubygems.org" platforms :ruby_19 do gem "httpi", "0.7.9" end -gem "savon" +gem "savon", "1.2.0" group :development do platforms :ruby_18 do diff --git a/README.textile b/README.textile index 53385a1..ca27d63 100644 --- a/README.textile +++ b/README.textile @@ -16,11 +16,28 @@ o en tu @Gemfile@ +Nota: Para que funcione el proceso de autenticación WSAA, chequear si el openssl instalado tiene compilado el módulo "cms" : +
openssl cms
+ +Si no dice "Invalid command", estamos bien. Caso contrario, descargar openssl y compilarlo con: +
./configure enable-cms
+make
+make install
+
+ +Nota 2: Ojo! Probablemente el openssl instalado originalmente sea el que se acceda por el path. Normalmente esto lo soluciona: + +
whereis openssl
+mv SALIDA_DEL_WHEREIS SALIDA_DEL_WHEREIS_old
+ln -s /usr/local/ssl/bin/openssl SALIDA_DEL_WHEREIS
+
h2. Configuración Los servicios de AFIP requieren la utilización del Web Service de Autorización y Autenticación ("wsaa readme":http://www.afip.gov.ar/ws/WSAA/README.txt) +Nota: El proceso de WSAA en Bravo está implementado con un script Bash. Esto es incompatible con un servidor Windows. + Luego de cumplidos los pasos indicados en el readme, basta con configurar Bravo con la ruta a los archivos:
diff --git a/bravo.gemspec b/bravo.gemspec
index e663766..7085c67 100644
--- a/bravo.gemspec
+++ b/bravo.gemspec
@@ -58,7 +58,7 @@ Gem::Specification.new do |s|
     s.specification_version = 3
 
     if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
-      s.add_runtime_dependency(%q, [">= 0"])
+      s.add_runtime_dependency(%q, ["= 1.2.0"])
       s.add_development_dependency(%q, [">= 0"])
       s.add_development_dependency(%q, ["= 0.11.24"])
       s.add_development_dependency(%q, ["= 0.11.6"])
@@ -67,7 +67,7 @@ Gem::Specification.new do |s|
       s.add_development_dependency(%q, ["~> 1.5.1"])
       s.add_development_dependency(%q, [">= 0"])
     else
-      s.add_dependency(%q, [">= 0"])
+      s.add_dependency(%q, ["= 1.2.0"])
       s.add_dependency(%q, [">= 0"])
       s.add_dependency(%q, ["= 0.11.24"])
       s.add_dependency(%q, ["= 0.11.6"])
@@ -77,7 +77,7 @@ Gem::Specification.new do |s|
       s.add_dependency(%q, [">= 0"])
     end
   else
-    s.add_dependency(%q, [">= 0"])
+    s.add_dependency(%q, ["= 1.2.0"])
     s.add_dependency(%q, [">= 0"])
     s.add_dependency(%q, ["= 0.11.24"])
     s.add_dependency(%q, ["= 0.11.6"])
diff --git a/lib/bravo.rb b/lib/bravo.rb
index a0ad78f..9232c21 100644
--- a/lib/bravo.rb
+++ b/lib/bravo.rb
@@ -5,6 +5,10 @@
 require "bravo/core_ext/float"
 require "bravo/core_ext/hash"
 require "bravo/core_ext/string"
+
+require 'net/http'
+require 'net/https'
+             
 module Bravo
 
   class NullOrInvalidAttribute < StandardError; end
@@ -26,10 +30,20 @@ def auth_hash
   def log?
     Bravo.verbose || ENV["VERBOSE"]
   end
+  
+  def deleteToken
+      AuthData.deleteToken
+  end
+
+  def token_modified_at
+      AuthData.token_modified_at
+  end
+
 
 end
 
 Savon.configure do |config|
-  config.log = Bravo.log?
+  config.logger = Rails.logger
+  config.log = true # Bravo.log?
   config.log_level = :debug
 end
diff --git a/lib/bravo/auth_data.rb b/lib/bravo/auth_data.rb
index 1b10f8e..1d35237 100644
--- a/lib/bravo/auth_data.rb
+++ b/lib/bravo/auth_data.rb
@@ -11,19 +11,49 @@ def fetch
           raise "Archivo certificado no encontrado en #{Bravo.cert}"
         end
 
-        todays_datafile = "/tmp/bravo_#{Time.new.strftime('%d_%m_%Y')}.yml"
         opts = "-u #{Bravo.auth_url}"
         opts += " -k #{Bravo.pkey}"
-        opts += " -c #{Bravo.cert}"
+        opts += " -c #{Bravo.cert}" 
+        opts += " -a #{todays_datafile}"
 
-        unless File.exists?(todays_datafile)
-          %x(#{File.dirname(__FILE__)}/../../wsaa-client.sh #{opts})
+        unless File.exists?(todays_datafile) and File.size(todays_datafile) > 29 # Avoid empty tokens
+          File.delete(todays_datafile) if File.exists?(todays_datafile)
+          command = "#{File.dirname(__FILE__)}/../../wsaa-client.sh #{opts}"
+          Rails.logger.warn "Haciendo request a WSAA: " + command
+          rsp = %x(bash #{command} )
         end
 
         @data = YAML.load_file(todays_datafile).each do |k, v|
-          Bravo.const_set(k.to_s.upcase, v) unless Bravo.const_defined?(k.to_s.upcase)
+          Bravo.const_set(k.to_s.upcase, v) #unless Bravo.const_defined?(k.to_s.upcase)
         end
+
+        error_msg = nil
+        if @data["error"].present?
+          File.delete(todays_datafile) if File.exist?(todays_datafile)
+          error_msg = "Error autentificando con AFIP: #{@data["error"]}"
+        elsif not File.exists?(todays_datafile)
+          error_msg = "Error autentificando con AFIP, vuelva a intentar."
+        end
+
+        if error_msg
+          Rails.logger.warn error_msg
+          raise error_msg
+        end
+
       end
+
+      def deleteToken
+        %x(rm #{todays_datafile})
+      end
+      
+      def token_modified_at
+        File.exist?(todays_datafile) ? File.mtime(todays_datafile) : nil
+      end
+
+      def todays_datafile
+        Dir.pwd + "/tmp/bravo_#{Bravo.cuit}_#{Time.new.strftime('%d_%m_%Y')}.yml"
+      end
+
     end
   end
 end
diff --git a/lib/bravo/bill.rb b/lib/bravo/bill.rb
index 40e0920..e2c9e94 100644
--- a/lib/bravo/bill.rb
+++ b/lib/bravo/bill.rb
@@ -1,9 +1,10 @@
 module Bravo
   class Bill
     attr_reader :client, :base_imp, :total
-    attr_accessor :net, :doc_num, :iva_cond, :documento, :concepto, :moneda,
-                  :due_date, :aliciva_id, :fch_serv_desde, :fch_serv_hasta,
-                  :body, :response
+    attr_accessor :net, :doc_num, :iva_cond, :recipient_iva_cond,
+                  :documento, :concepto, :moneda,
+                  :due_date, :fch_serv_desde, :fch_serv_hasta, :fch_emision,
+                  :body, :response, :ivas, :related_invoice_data
 
     def initialize(attrs = {})
       Bravo::AuthData.fetch
@@ -15,6 +16,11 @@ def initialize(attrs = {})
         http.read_timeout = 90
         http.open_timeout = 90
         http.headers = { "Accept-Encoding" => "gzip, deflate", "Connection" => "Keep-Alive" }
+
+        # Insist because this config doesn't stick from bravo.rb for some reason
+        config.logger = Rails.logger
+        config.log = true
+        config.log_level = :debug
       end
 
       @body           = {"Auth" => Bravo.auth_hash}
@@ -23,6 +29,7 @@ def initialize(attrs = {})
       self.moneda     = attrs[:moneda]    || Bravo.default_moneda
       self.iva_cond   = attrs[:iva_cond]
       self.concepto   = attrs[:concepto]  || Bravo.default_concepto
+      self.ivas = attrs[:ivas] || Array.new # [ 1, 100.00, 10.50 ], [ 2, 100.00, 21.00 ] 
     end
 
     def cbte_type
@@ -44,8 +51,14 @@ def total
     end
 
     def iva_sum
-      @iva_sum = net * Bravo::ALIC_IVA[aliciva_id][1]
-      @iva_sum.round_up_with_precision(2)
+      @iva_sum = 0.0
+      self.ivas.each{ |i|
+        @iva_sum += i[1] * Bravo::ALIC_IVA[ i[0] ][1]
+        # @iva_sum += i[2] 
+      }
+      #@iva_sum = net * Bravo::ALIC_IVA[TODO][1]
+      #@iva_sum.round_up_with_precision(2)
+      @iva_sum.round(2)
     end
 
     def authorize
@@ -60,7 +73,20 @@ def authorize
     end
 
     def setup_bill
-      today = Time.new.strftime('%Y%m%d')
+      if fch_emision then
+        fecha_emision = fch_emision.strftime('%Y%m%d')
+      else
+        fecha_emision = Time.new.strftime('%Y%m%d') #today
+      end
+       
+
+      array_ivas = Array.new
+      self.ivas.each{ |i|
+          array_ivas << {
+              "Id" => Bravo::ALIC_IVA[ i[0] ][0],
+              "BaseImp" => i[1] ,
+              "Importe" => i[2] }
+      }
 
       fecaereq = {"FeCAEReq" => {
                     "FeCabReq" => Bravo::Bill.header(cbte_type),
@@ -68,27 +94,52 @@ def setup_bill
                       "FECAEDetRequest" => {
                         "Concepto"    => Bravo::CONCEPTOS[concepto],
                         "DocTipo"     => Bravo::DOCUMENTOS[documento],
-                        "CbteFch"     => today,
+                        "CbteFch"     => fecha_emision,
                         "ImpTotConc"  => 0.00,
                         "MonId"       => Bravo::MONEDAS[moneda][:codigo],
                         "MonCotiz"    => exchange_rate,
                         "ImpOpEx"     => 0.00,
-                        "ImpTrib"     => 0.00,
-                        "Iva"         => {
-                          "AlicIva" => {
-                            "Id" => "5",
-                            "BaseImp" => net,
-                            "Importe" => iva_sum}}}}}}
+                        "ImpTrib"     => 0.00
+                    }}}}
+
+      if Bravo.own_iva_cond == :responsable_inscripto
+        fecaereq["FeCAEReq"]["FeDetReq"]["FECAEDetRequest"]["Iva"] = { "AlicIva" => array_ivas }
+      elsif Bravo.own_iva_cond == :responsable_monotributo and array_ivas.any?
+        raise "No incluir montos iva al facturar como Responsable Monotributo"
+      end
+
+      self.recipient_iva_cond = parse_iva_cond(self.recipient_iva_cond) if self.recipient_iva_cond.is_a?(Symbol)
+      if not self.recipient_iva_cond.is_a?(Integer)
+        raise "La condición IVA del receptor es un campo requerido desde el 1/8/2025"
+      end
 
       detail = fecaereq["FeCAEReq"]["FeDetReq"]["FECAEDetRequest"]
 
       detail["DocNro"]    = doc_num
+      detail["CondicionIVAReceptorId"] = self.recipient_iva_cond
       detail["ImpNeto"]   = net.to_f
       detail["ImpIVA"]    = iva_sum
-      detail["ImpTotal"]  = total
+      detail["ImpTotal"]  = total.round(2)
       detail["CbteDesde"] = detail["CbteHasta"] = next_bill_number
 
-      unless concepto == 0
+      if related_invoice_data
+        detail["CbtesAsoc"] = {
+          "CbteAsoc" => [{
+            "Tipo" => related_invoice_data[:type],
+            "PtoVta" => related_invoice_data[:sale_point],
+            "Nro" => related_invoice_data[:number],
+            "Cuit" => related_invoice_data[:cuit],
+            "CbteFch" => related_invoice_data[:date]
+          }]
+        }
+      elsif ["02", "03", "07", "08"].include?(cbte_type) # NC/ND only
+        detail["PeriodoAsoc"] = {
+          "FchDesde" => fch_emision.beginning_of_month.strftime('%Y%m%d'),
+          "FchHasta" => fch_emision.strftime('%Y%m%d')
+        }
+      end
+
+      unless concepto == "Productos" # En "Productos" ("01"), si se mandan estos parámetros la afip rechaza.
         detail.merge!({"FchServDesde" => fch_serv_desde || today,
                       "FchServHasta"  => fch_serv_hasta || today,
                       "FchVtoPago"    => due_date       || today})
@@ -106,8 +157,27 @@ def next_bill_number
       resp.to_hash[:fe_comp_ultimo_autorizado_response][:fe_comp_ultimo_autorizado_result][:cbte_nro].to_i + 1
     end
 
-    def authorized?
-      !response.nil? && response.header_result == "A" && response.detail_result == "A"
+    def authorized?       
+        !response.nil? && response.header_result == "A" && response.detail_result == "A"
+    end
+
+    def search(cbte_type, cbte_nro, pto_vta)
+      fecompconsultarreq = {
+        "FeCompConsReq" => {
+          "CbteTipo" => cbte_type,
+          "CbteNro" => cbte_nro,
+          "PtoVta" => pto_vta
+        }
+      }
+      body.merge!(fecompconsultarreq)
+
+      response = client.request("FECompConsultar", soap_action: "http://ar.gov.afip.dif.FEV1/FECompConsultar") do |soap|
+        soap.namespaces["xmlns"] = "http://ar.gov.afip.dif.FEV1/"
+        soap.body = body
+      end
+      puts "FECompConsultar --> "
+      puts body
+      return response.to_json
     end
 
     private
@@ -122,16 +192,34 @@ def setup_response(response)
       # TODO: turn this into an all-purpose Response class
 
       result          = response[:fecae_solicitar_response][:fecae_solicitar_result]
-
+          
+      if not result[:fe_det_resp] or not result[:fe_cab_resp] then 
+      # Si no obtuvo respuesta ni cabecera ni detalle, evito hacer '[]' sobre algo indefinido.                        
+      # Ejemplo: Error con el token-sign de WSAA
+          keys, values = {
+                :errores => result[:errors],
+                :header_result => {:resultado => "X" },
+                :observaciones => nil
+          }.to_a.transpose
+          self.response = (defined?(Struct::ResponseMal) ? Struct::ResponseMal : Struct.new("ResponseMal", *keys)).new(*values)         
+          return
+      end       
+      
       response_header = result[:fe_cab_resp]
       response_detail = result[:fe_det_resp][:fecae_det_response]
 
       request_header  = body["FeCAEReq"]["FeCabReq"].underscore_keys.symbolize_keys
       request_detail  = body["FeCAEReq"]["FeDetReq"]["FECAEDetRequest"].underscore_keys.symbolize_keys
-
-      iva             = request_detail.delete(:iva)["AlicIva"].underscore_keys.symbolize_keys
-
-      request_detail.merge!(iva)
+      
+      # Esto no funciona desde que se soportan múltiples alícuotas de iva simultáneas
+      # FIX ? TO-DO
+      # iva             = request_detail.delete(:iva)["AlicIva"].underscore_keys.symbolize_keys
+      # request_detail.merge!(iva) 
+         
+      if result[:errors] then
+          response_detail.merge!( result[:errors] )     
+      end
+      
 
       response_hash = {:header_result => response_header.delete(:resultado),
                        :authorized_on => response_header.delete(:fch_proceso),
@@ -143,11 +231,23 @@ def setup_response(response)
                        :moneda        => request_detail.delete(:mon_id),
                        :cotizacion    => request_detail.delete(:mon_cotiz),
                        :iva_base_imp  => request_detail.delete(:base_imp),
-                       :doc_num       => request_detail.delete(:doc_nro)
+                       :doc_num       => request_detail.delete(:doc_nro), 
+                       :observaciones => response_detail.delete(:observaciones),
+                       :errores       => response_detail.delete(:err) 
                        }.merge!(request_header).merge!(request_detail)
 
       keys, values  = response_hash.to_a.transpose
-      self.response = (defined?(Struct::Response) ? Struct::Response : Struct.new("Response", *keys)).new(*values)
+      
+      #self.response = (defined?(Struct::Response) ? Struct::Response : Struct.new("Response", *keys)).new(*values)
+      # Even if it throws a warning, it avoids some parsing errors.
+      self.response = Struct.new("Response", *keys).new(*values)
+    end
+
+    def parse_iva_cond(sym)
+      return 1 if sym == :responsable_inscripto
+      return 6 if sym == :monotributo
+      return 5 if sym == :consumidor_final
+      return 4 if sym == :exento
     end
   end
 end
diff --git a/lib/bravo/constants.rb b/lib/bravo/constants.rb
index 27f11c9..09dc1f9 100644
--- a/lib/bravo/constants.rb
+++ b/lib/bravo/constants.rb
@@ -40,9 +40,19 @@ module Bravo
       :responsable_inscripto => "01",
       :consumidor_final => "06",
       :exento => "06",
-      :responsable_monotributo => "06",
+      :responsable_monotributo => "01", # In 2021 RIs started generating A invoices for MOs. Formerly B: "06",
       :nota_credito_a => "03",
-      :nota_credito_b => "08"
+      :nota_credito_b => "08",
+      :nota_debito_a => "02",
+      :nota_debito_b => "07"
+    },
+    responsable_monotributo: {
+      responsable_inscripto: "11",
+      consumidor_final: "11",
+      exento: "11",
+      responsable_monotributo: "11",
+      nota_credito_c: "13",
+      nota_debito_c: "12"
     }
   }
 end
diff --git a/spec/bravo/bill_spec.rb b/spec/bravo/bill_spec.rb
index 850a583..252d943 100644
--- a/spec/bravo/bill_spec.rb
+++ b/spec/bravo/bill_spec.rb
@@ -50,7 +50,8 @@
       @bill.iva_cond = :responsable_inscripto
       @bill.moneda = :peso
       @bill.net = 100.89
-      @bill.aliciva_id = 2
+
+      @bill.ivas << [ 2, 100.89 , 21.18 ]      
 
       @bill.iva_sum.should be_within(0.05).of(21.18)
       @bill.total.should be_within(0.05).of(122.07)
@@ -58,7 +59,9 @@
 
     it "should use give due date an service dates, or todays date" do
       @bill.net = 100
-      @bill.aliciva_id = 2
+
+      @bill.ivas << [ 2, 100 , 21 ]      
+      
       @bill.doc_num = "30710151543"
       @bill.iva_cond = :responsable_inscripto
       @bill.concepto = "Servicios"
@@ -87,7 +90,10 @@
     Bravo::BILL_TYPE[Bravo.own_iva_cond].keys.each do |target_iva_cond|
       it "should authorize a valid bill for #{target_iva_cond.to_s}" do
         @bill.net = 1000000
-        @bill.aliciva_id = 2
+        
+        @bill.ivas << [ 2, 1000000 , 210000 ]      
+
+        
         @bill.doc_num = "30710151543"
         @bill.iva_cond = target_iva_cond
         @bill.concepto = "Servicios"
@@ -105,7 +111,9 @@
 
     it "should authorize nota de credito A" do
       @bill.net = 10000
-      @bill.aliciva_id = 2
+      
+      @bill.ivas << [ 2, 10000 , 2100 ]      
+      
       @bill.doc_num = "30710151543"
       @bill.iva_cond = :nota_credito_a
       @bill.concepto = "Servicios"
@@ -123,7 +131,9 @@
 
     it "should authorize nota de credito B" do
       @bill.net = 10000
-      @bill.aliciva_id = 2
+      
+      @bill.ivas << [ 2, 10000 , 2100 ]      
+      
       @bill.doc_num = "30710151543"
       @bill.iva_cond = :nota_credito_b
       @bill.concepto = "Servicios"
diff --git a/wsaa-client.sh b/wsaa-client.sh
old mode 100755
new mode 100644
index 76952d9..a76b84a
--- a/wsaa-client.sh
+++ b/wsaa-client.sh
@@ -46,9 +46,9 @@ function MakeCMS()
 {
   CMS=$(
     echo "$TRA" |
-    /usr/local/ssl/bin/openssl cms -sign -in /dev/stdin -signer $CRT -inkey $KEY -nodetach \
+    openssl cms -sign -in /dev/stdin -signer $CRT -inkey $KEY -nodetach \
             -outform der |
-    /usr/local/ssl/bin/openssl base64 -e
+    openssl base64 -e
   )
 }
 #------------------------------------------------------------------------------
@@ -109,8 +109,14 @@ function ParseTA()
 if [ "$TOKEN" == "" ]
   then
     echo "ERROR: "
-    echo "$(echo "$RESPONSE" | xmllint --format - | grep faultstring)"
-    exit 1
+    ERROR=$(
+      echo "$RESPONSE" |
+      xmllint --format - | 
+      grep faultstring | 
+      xargs
+    )
+    echo $ERROR
+    #exit 1
 fi
 }
 #------------------------------------------------------------------------------
@@ -132,10 +138,18 @@ EOF
 
 function WriteYAML()
 {
-	cat < /tmp/bravo_$(date +"%d_%m_%Y").yml
+cat < $DATAFILE
 token: '$TOKEN'
 sign: '$SIGN'
 EOF
+
+if [ "$TOKEN" == "" ]
+  then
+cat <> $DATAFILE
+error: '$ERROR'
+EOF
+fi
+
 }
 #------------------------------------------------------------------------------
 #
@@ -147,7 +161,7 @@ EOF
 #[ $# -eq 0 ] && read -p "Service name: " SERVICE
 
 # Parse commandline arguments
-while getopts 'k:u:c:' OPTION
+while getopts 'k:u:c:a:' OPTION
 do
     case $OPTION in
     c)    CRT=$OPTARG
@@ -155,9 +169,13 @@ do
     k)    KEY=$OPTARG
         ;;
     u)    URL=$OPTARG
+        ;;             
+    a)    DATAFILE=$OPTARG
         ;;
     esac
-done
+done             
+echo "Using output file $DATAFILE"
+
 shift $(($OPTIND - 1))
 MakeTRA          # Generate TRA
 MakeCMS          # Generate CMS (TRA + signature + certificate)