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)