diff --git a/.gitignore b/.gitignore index 008b9c3..6bbafcb 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ tmtags log/ log/*.log ./fixtures/ -./fixtures/vcr_cassettes/ +*/**/vcr_cassettes/ *.gem tmp/ bin/bravo-certs/ diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..02b58e9 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,157 @@ +--- +AllCops: + Exclude: + - 'bin/*' + +Metrics/CyclomaticComplexity: + Severity: refactor + Max: 8 + +Metrics/LineLength: + Max: 110 + Severity: refactor + +Metrics/ClassLength: + Max: 150 + Severity: refactor + +Metrics/MethodLength: + Max: 15 + Severity: refactor + +Metrics/ParameterLists: + Max: 5 + Severity: refactor + +Metrics/PerceivedComplexity: + Max: 10 + Severity: refactor + +Lint/EndAlignment: + AlignWith: variable + +Lint/UselessAssignment: + Severity: error + +Lint/ShadowingOuterLocalVariable: + Severity: convention + +Style/CaseEquality: + Enabled: false + +Style/Documentation: + Enabled: false + Severity: refactor + +Style/IfUnlessModifier: + MaxLineLength: 80 + +Style/GuardClause: + MinBodyLength: 3 + +Style/Lambda: + Enabled: false + +Style/EmptyLineBetweenDefs: + AllowAdjacentOneLineDefs: true + +Style/ClassAndModuleChildren: + Enabled: false + +Style/AndOr: + EnforcedStyle: conditionals + +# This one is usefull for class attr_* +Style/TrivialAccessors: + AllowDSLWriters: true + +Style/AlignParameters: + EnforcedStyle: with_fixed_indentation + +Style/AlignHash: + EnforcedHashRocketStyle: key + EnforcedLastArgumentHashStyle: always_ignore + +Style/ModuleFunction: + Enabled: false + +Style/RegexpLiteral: + # The maximum number of (escaped) slashes that a slash-delimited regexp is + # allowed to have. If there are more slashes, a %r regexp shall be used. + MaxSlashes: 0 + +Style/FormatString: + Enabled: false + +Style/SingleLineBlockParams: + Enabled: false + +Style/CaseIndentation: + IndentWhenRelativeTo: end + +Style/PredicateName: + Severity: refactor + +Style/IndentHash: + EnforcedStyle: consistent + +Style/MultilineBlockChain: + Severity: refactor + +Lint/AssignmentInCondition: + Enabled: false + +Style/Alias: + Enabled: false + +Style/StringLiterals: + Enabled: false + +Style/SpaceInsideBlockBraces: + SpaceBeforeBlockParameters: true + +Style/PercentLiteralDelimiters: + PreferredDelimiters: + '%i': '[]' + '%w': '[]' + '%W': '[]' + '%': '{}' + +Style/CollectionMethods: + PreferredMethods: + reduce: inject + collect: map + collect!: 'map!' + detect: find + detect!: 'find!' + find_all: select + find_all!: 'select!' + +Style/DoubleNegation: + Severity: refactor + +Style/SignalException: + EnforcedStyle: only_raise + +Style/DotPosition: + EnforcedStyle: trailing + +Style/SingleLineMethods: + AllowIfMethodIsEmpty: true + +# It doesnt handle cases when we want to align multiple methods call into a table like +Style/SingleSpaceBeforeFirstArg: + Enabled: false + +# replaces $1 x Regexp.last_match[1] +Style/PerlBackrefs: + Enabled: false + +# There are valid cases (eg. Date.parse(date) rescue nil) +Style/RescueModifier: + Enabled: false + +Style/Blocks: + Severity: refactor + Exclude: + - '**/spec/**/*_spec.rb' diff --git a/.travis.yml b/.travis.yml index 8c01670..0141ece 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,10 @@ language: ruby rvm: - - 1.9.3 - 2.0.0 -bundler_args: --without test -branches: - only: - - master + - 2.1.2 + - 2.2.0 +script: + - rm -rf spec/fixtures/vcr_cassettes/ + - if [ -e /tmp/bravo* ]; then rm /tmp/bravo*; fi + - bundle exec rspec spec --format=progress + - bundle exec rubocop diff --git a/CHANGELOG b/CHANGELOG index a64d9a4..0ec1c13 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,6 @@ +*Bravo 1.0.0 (june 01, 2015)* +* Refactored auth data to expire after 12 hours + *Bravo 1.0.0.rc1 (November 25, 2013)* * Added full support for Savon logging options diff --git a/Gemfile b/Gemfile index 8021c6c..87a7951 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,4 @@ source 'http://rubygems.org' -group :test do - gem 'guard-rspec', '~> 2.4.0' - gem 'rb-fsevent', '~> 0.9.1' - gem 'debugger', '~> 1.3.0' -end - -gemspec \ No newline at end of file +gem 'byebug', group: :test +gemspec diff --git a/Guardfile b/Guardfile deleted file mode 100644 index 70b4a18..0000000 --- a/Guardfile +++ /dev/null @@ -1,5 +0,0 @@ -guard 'rspec', cli: '--color --format nested' do - watch(%r{^spec/.+_spec\.rb$}) - watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } - watch('spec/spec_helper.rb') { "spec" } -end \ No newline at end of file diff --git a/README.md b/README.md index 821bf25..7d77774 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Bravo +# Bravo ![Travis status](https://travis-ci.org/leanucci/bravo.png) [![Gem Version](https://badge.fury.io/rb/bravo.png)](http://badge.fury.io/rb/bravo) [![Code Climate](https://codeclimate.com/repos/5292a01e89af7e473304513a/badges/4a29fbaff3d74a23e634/gpa.png)](https://codeclimate.com/repos/5292a01e89af7e473304513a/feed) @@ -7,18 +7,10 @@ ## Requisitos -Para poder autorizar comprobantes mediante el WSFE, AFIP requiere de ciertos pasos detallados a continuación: - -* Generar una clave privada para la aplicación. -* Generar un CSR (Certificate Signing Request) utilizando el número de CUIT que emitirá los comprobantes y la clave privada del paso anterior. Se deberá enviar a AFIP el CSR para obtener el Certificado X.509 que se utilizará en el proceso de autorización de comprobantes. - * Para el entorno de Testing, se debe enviar el X.509 por email a _webservices@afip.gov.ar_. - * Para el entorno de Producción, el trámite se hace a través del portal [AFIP](http://www.afip.gov.ar) -* El certificado X.509 y la clave privada son utilizados por Bravo para obtener el token y signature a incluir en el header de autenticacion en cada request que hagamaos a los servicios de AFIP. - - +Guía para obtener certificados WSAA [aquí](http://www.afip.gov.ar/ws/WSAA/cert-req-howto.txt) ### OpenSSL -Para cumplir con los requisitos de encriptación del [Web Service de Autenticación y Autorización](http://www.afip.gov.ar/ws/WSAA/README.txt) (WSAA), Bravo requiere [OpenSSL](http://openssl.org) en cualquier versión posterior a la 1.0.0a. +Para cumplir con los requisitos de encriptación del [Web Service de Autenticación y Autorización](http://www.afip.gov.ar/ws/WSAA/README.txt) (WSAA), Bravo requiere [OpenSSL](http://openssl.org) en cualquier versión igual o posterior a la 1.0.0a. Como regla general, basta correr desde la línea de comandos ```openssl cms``` @@ -26,7 +18,7 @@ Si el comando ```cms``` no está disponible, se debe actualizar OpenSSL. ### Certificados -AFIP exige para acceder a sus Web Services, la utilización del WSAA. Este servicio se encarga de la autorización y autenticación de cada request hecho al web service. +AFIP exige estar identificado para acceder a sus Web Services, mediante la utilización del WSAA. Este servicio se encarga de la autorización y autenticación de cada request hecho al web service. Para esto, hay que generar un mensaje encriptado, que se utiliza para el login en el WSAA. Si este login es exitoso, se obtienen credenciales válidas por 12 horas. Una vez instalada la version correcta de OpenSSL, podemos generar la clave privada y el CSR. @@ -38,9 +30,22 @@ Una vez instalada la version correcta de OpenSSL, podemos generar la clave priva Luego de haber obtenido el certificado X.509, podemos comenzar a utilizar Bravo en el entorno para el cual sirve el certificado. -### Configuración +### Autorización + +Cada mensajeLa clase Authorizations es la encargada de manejar las credenciales con las que se autentican los mensajes +enviados a afip. + +El primer paso, es crear una nueva instancia de Bravo::Authorization con el número de cuit y la ruta a los +archivos de certificado y clave privada: + +```ruby +authorization = Bravo::Authorization.new +authorization.cuit = '2028774002' +authorization.pkey_path = 'path/al/pkey' +authorization.cert_path = 'path/al/cert' +``` + -Bravo no asume valores por defecto, por lo cual hay que configurar de forma explícita todos los parámetros: * ```pkey``` ruta a la clave privada * ```cert``` ruta al certificado X.509 @@ -53,20 +58,23 @@ Bravo no asume valores por defecto, por lo cual hay que configurar de forma expl Ejemplo de configuración tomado del spec_helper de Bravo: +```ruby - require 'bravo' +require 'bravo' - Bravo.pkey = 'spec/fixtures/certs/pkey' - Bravo.cert = 'spec/fixtures/certs/cert.crt' - Bravo.cuit = '20287740027' - Bravo.sale_point = '0002' - Bravo.default_concepto = 'Productos y Servicios' - Bravo.default_documento = 'CUIT' - Bravo.default_moneda = :peso - Bravo.own_iva_cond = :responsable_inscripto - Bravo.verbose = 'true' - Bravo.openssl_bin = '/usr/local/Cellar/openssl/1.0.1e/bin/openssl' - Bravo::AuthData.environment = :test +Bravo.pkey = 'spec/fixtures/certs/pkey' +Bravo.cert = 'spec/fixtures/certs/cert.crt' +Bravo.cuit = '20287740027' +Bravo.sale_point = '0002' +Bravo.default_concepto = 'Productos y Servicios' +Bravo.default_documento = 'CUIT' +Bravo.default_moneda = :peso +Bravo.own_iva_cond = :responsable_inscripto +Bravo.verbose = 'true' +Bravo.openssl_bin = '/usr/local/Cellar/openssl/1.0.1e/bin/openssl' +Bravo::AuthData.environment = :test + +``` ### Emisión de comprobantes @@ -88,18 +96,21 @@ Luego de configurar Bravo, autorizamos una factura: Código de ejemplo para la configuración anterior: +```ruby + +bill = Bravo::Bill.new - factura = Bravo::Bill.new +bill.net = 100.00 # el neto de la factura, total para Consumidor final +bill.aliciva_id = 2010 # define la alicuota de iva a utilizar, ver archivo constants. +bill.iva_cond = :consumidor_final # la condición ante el iva del comprador +bill.concepto = 'Servicios' # concepto de la factura +bill.invoice_type = :invoice # el tipo de comprobante a emitir, en este caso factura. - factura.net = 100.00 # el neto de la factura, total para Consumidor final - factura.aliciva_id = 2 # define la alicuota de iva a utilizar, ver archivo constants. - factura.iva_cond = :consumidor_final # la condición ante el iva del comprador - factura.concepto = 'Servicios' # concepto de la factura - factura.invoice_type = :invoice # el tipo de comprobante a emitir, en este caso factura. +bill.authorize - bill.authorize +bill.response.cae # contiene el cae para este comprobante. - bill.response.cae # contiene el cae para este comprobante. +``` ## TODO list diff --git a/autotest/discover.rb b/autotest/discover.rb index 42aea33..87dbf24 100644 --- a/autotest/discover.rb +++ b/autotest/discover.rb @@ -1 +1 @@ -Autotest.add_discovery { "rspec2" } #added according to rspec2 book \ No newline at end of file +Autotest.add_discovery { "rspec2" } # added according to rspec2 book diff --git a/bin/bravo b/bin/bravo index fa439cf..1e679ef 100755 --- a/bin/bravo +++ b/bin/bravo @@ -2,19 +2,23 @@ # -*- encoding: utf-8 -*- require 'thor' - +# base module module Bravo # Bravo executable for certificate request generation # class Bravo < Thor - desc 'gencsr', 'Crea el Certificate Signature Request' method_option :bin, type: :string, required: true, desc: 'El path completo al binario de openssl' - method_option :pkey, type: :string, desc: 'Path a una clave privada preexistente. Si se omite, se crea una clave en --out' - method_option :sn, type: :string, required: true, desc: 'Nombre del servidor. Sin uso práctico, es requerido por AFIP' - method_option :cn, type: :string, required: true, desc: 'Nombre de la compañía. Sin uso práctico, es requerido por AFIP' - method_option :cuit, type: :numeric, required: true, desc: 'Número de CUIT sin guiones. Ejemplo: 20876543217' - method_option :out, type: :string, default: 'bravo-certs', desc: 'Directorio de destino para los archivos creados. Si se omite, se crea el directorio bravo-certs en pwd' + method_option :pkey, type: :string, + desc: 'Path a una clave privada preexistente. Si se omite, se crea una clave en --out' + method_option :sn, type: :string, required: true, + desc: 'Nombre del servidor. Sin uso práctico, es requerido por AFIP' + method_option :cn, type: :string, required: true, + desc: 'Nombre de la compañía. Sin uso práctico, es requerido por AFIP' + method_option :cuit, type: :numeric, required: true, + desc: 'Número de CUIT sin guiones. Ejemplo: 20876543217' + method_option :out, type: :string, default: 'bravo-certs', + desc: 'Directorio de destino para los archivos creados. Default: "./bravo-certs"' # Certificate Signature Request wrapper for bravo. # @@ -28,7 +32,7 @@ module Bravo cuit = options[:cuit] out = options[:out] - Dir.mkdir(out) unless File.exists?(out) + Dir.mkdir(out) unless File.exist?(out) out_path = "#{ Dir.pwd }/#{ out }/" @@ -36,18 +40,17 @@ module Bravo say("Creando CSR en #{ out_path } con CUIT=#{ cuit }, o=#{ sn } y cn=#{ cn }", :cyan) - `#{bin} req -new \ - -key #{ pkey } \ - -subj "/C=AR/O=#{ sn }/CN=#{ cn }/serialNumber=CUIT #{ cuit }"\ + `#{bin} req -new -key #{ pkey } -subj "/C=AR/O=#{ sn }/CN=#{ cn }/serialNumber=CUIT #{ cuit }"\ -out #{ out_path }pedido-#{ cuit }` say('Hecho!', :green) end protected + # Creates a new private key # - def create_pkey(bin,out_path) + def create_pkey(bin, out_path) say('Creando pkey', :cyan) `#{ bin } genrsa -out #{ out_path }pkey 1024` say('Hecho!\n\n', :green) @@ -56,4 +59,4 @@ module Bravo end Bravo.start -end \ No newline at end of file +end diff --git a/bravo.gemspec b/bravo.gemspec index ccf1963..49beba4 100644 --- a/bravo.gemspec +++ b/bravo.gemspec @@ -8,24 +8,23 @@ Gem::Specification.new do |gem| gem.version = Bravo::VERSION gem.authors = ["Leandro Marcucci"] gem.email = ["leanucci@gmail.com"] - gem.description = %q{Adaptador para el Web Service de Facturacion Electrónica de AFIP} - gem.summary = %q{Adaptador WSFE} + gem.description = 'Adaptador para el Web Service de Facturacion Electrónica de AFIP' + gem.summary = 'Adaptador WSFE' gem.homepage = "https://github.com/leanucci/bravo#readme" - gem.date = %q(2011-03-14) + gem.date = '2011-03-14' - gem.files = `git ls-files`.split($/) + gem.files = `git ls-files`.split($RS) gem.files.reject! { |f| f.include? 'vcr' } - gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) } gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) - gem.require_paths = ["lib", "bin"] + gem.require_paths = %w[lib bin] - gem.add_runtime_dependency(%q, ["~> 2.3.0"]) - gem.add_runtime_dependency(%q, ["~> 0.17.0"]) + gem.add_runtime_dependency(%{savon}, ["~> 2.9.0"]) + gem.add_runtime_dependency(%{thor}, ["~> 0.19.0"]) - gem.add_development_dependency(%q, ["~> 2.14.0"]) - gem.add_development_dependency(%q, ["~> 2.14.0"]) - gem.add_development_dependency(%q, ["~> 10.0.0"]) - gem.add_development_dependency(%q, ["~> 2.4.0"]) - gem.add_development_dependency(%q, ["~> 0.7.0"]) - gem.add_development_dependency(%q, ["~> 1.3.0"]) + gem.add_development_dependency(%{rspec}, ["~> 3.2.0"]) + gem.add_development_dependency(%{rake}, ["~> 10.4.0"]) + gem.add_development_dependency(%{vcr}, ["~> 2.9.0"]) + gem.add_development_dependency(%{rubocop}, ["~> 0.31.0"]) + gem.add_development_dependency(%{webmock}, ["~> 1.18.0"]) end diff --git a/examples/invoices.rb b/examples/invoices.rb index 5b2d110..d0a2cd8 100644 --- a/examples/invoices.rb +++ b/examples/invoices.rb @@ -14,7 +14,7 @@ # Let's issue a Factura for 1200 ARS to a Responsable Inscripto bill_a = Bravo::Bill.new(iva_condition: :responsable_inscripto, net: 1200, invoice_type: :invoice) -bill_a.document_number = '30710151543' +bill_a.document_number = '30710151543' bill_a.document_type = 'CUIT' bill_a.authorize @@ -33,4 +33,3 @@ puts "Authorization result = #{ bill_b.authorized? }" puts "Authorization response." pp bill_b.response - diff --git a/lib/bravo.rb b/lib/bravo.rb index d06d62b..66a96ce 100644 --- a/lib/bravo.rb +++ b/lib/bravo.rb @@ -7,73 +7,83 @@ require 'bravo/core_ext/string' module Bravo - # Exception Class for missing or invalid attributes # class NullOrInvalidAttribute < StandardError; end - # Exception Class for missing or invalid certifficate # class MissingCertificate < StandardError; end - + class MissingCredentials < StandardError; end # This class handles the logging options # - class Logger < Struct.new(:log, :pretty_xml, :level) + class Logger # @param opts [Hash] receives a hash with keys `log`, `pretty_xml` (both # boolean) or the desired log level as `level` + attr_accessor :log, :pretty_xml, :level def initialize(opts = {}) - self.log = opts[:log] || false - self.pretty_xml = opts[:pretty_xml] || self.log - self.level = opts[:level] || :debug + self.log = opts.fetch(:log, false) + self.pretty_xml = opts.fetch(:pretty_xml, log) + self.level = opts.fetch(:level, :debug) end # @return [Hash] returns a hash with the proper logging optios for Savon. def logger_options - { log: self.log, pretty_print_xml: self.pretty_xml, log_level: self.level } + { log: log, pretty_print_xml: pretty_xml, log_level: level } end end - autoload :Authorizer, 'bravo/authorizer' - autoload :AuthData, 'bravo/auth_data' - autoload :Bill, 'bravo/bill' - autoload :Constants, 'bravo/constants' - autoload :Wsaa, 'bravo/wsaa' - autoload :Reference, 'bravo/reference' + autoload :Authorizer, 'bravo/authorizer' + autoload :Bill, 'bravo/bill' + autoload :Constants, 'bravo/constants' + autoload :Authorization, 'bravo/authorization' + autoload :Wsaa, 'bravo/wsaa' + autoload :Reference, 'bravo/reference' + autoload :Request, 'bravo/request' extend self - attr_accessor :cuit, :sale_point, :default_documento, :pkey, :cert, - :default_concepto, :default_moneda, :own_iva_cond, - :openssl_bin + attr_accessor :sale_point, :default_documento, :default_concepto, :default_moneda, + :own_iva_cond, :openssl_bin - class << self - # Receiver of the logging configuration options. - # @param opts [Hash] pass a hash with `log`, `pretty_xml` and `level` keys to set - # them. - def logger=(opts) - @logger ||= Logger.new(opts) - end + attr_reader :environment - # Sets the logger options to the default values or returns the previously set - # logger options - # @return [Logger] - def logger - @logger ||= Logger.new + # Used to set the environment. Validates the value with the existing environments + # + def self.environment=(env) + env = env.to_sym + if Bravo::URLS.keys.include?(env) + @environment = env + else + raise "invalid environment: #{ env }. Choose one from [#{ Bravo::URLS.keys.join(', ') }]" end + end - # Returs the formatted logger options to be used by Savon. - def logger_options - logger.logger_options - end + # Receiver of the logging configuration options. + # @param opts [Hash] pass a hash with `log`, `pretty_xml` and `level` keys to set + # them. + def self.logger=(opts) + @logger = Logger.new(opts) + end + + # Sets the logger options to the default values or returns the previously set + # logger options + # @return [Logger] + def self.logger + @logger ||= Logger.new + end + + # Returs the formatted logger options to be used by Savon. + def self.logger_options + logger.logger_options + end - def own_iva_cond=(iva_cond_symbol) - if Bravo::BILL_TYPE.has_key?(iva_cond_symbol) - @own_iva_cond = iva_cond_symbol - else - raise(NullOrInvalidAttribute.new, "El valor de own_iva_cond: (#{ iva_cond_symbol }) es inválido.") - end + def self.own_iva_cond=(iva_cond_symbol) + if Bravo::BILL_TYPE.key?(iva_cond_symbol) + @own_iva_cond = iva_cond_symbol + else + raise(NullOrInvalidAttribute.new, "El valor de own_iva_cond: (#{ iva_cond_symbol }) es inválido.") end end end diff --git a/lib/bravo/auth_data.rb b/lib/bravo/auth_data.rb deleted file mode 100644 index 754ac7a..0000000 --- a/lib/bravo/auth_data.rb +++ /dev/null @@ -1,65 +0,0 @@ -module Bravo - - # This class handles authorization data - # - class AuthData - - class << self - - attr_accessor :environment, :todays_data_file_name - - # Fetches WSAA Authorization Data to build the datafile for the day. - # It requires the private key file and the certificate to exist and - # to be configured as Bravo.pkey and Bravo.cert - # - def fetch - unless File.exists?(Bravo.pkey) - raise "Archivo de llave privada no encontrado en #{ Bravo.pkey }" - end - - unless File.exists?(Bravo.cert) - raise "Archivo certificado no encontrado en #{ Bravo.cert }" - end - - unless File.exists?(todays_data_file_name) - Bravo::Wsaa.login - end - - YAML.load_file(todays_data_file_name).each do |k, v| - Bravo.const_set(k.to_s.upcase, v) unless Bravo.const_defined?(k.to_s.upcase) - end - end - - # Returns the authorization hash, containing the Token, Signature and Cuit - # @return [Hash] - # - def auth_hash - fetch unless Bravo.constants.include?(:TOKEN) && Bravo.constants.include?(:SIGN) - { 'Token' => Bravo::TOKEN, 'Sign' => Bravo::SIGN, 'Cuit' => Bravo.cuit } - end - - # Returns the right wsaa url for the specific environment - # @return [String] - # - def wsaa_url - raise 'Environment not sent to either :test or :production' unless Bravo::URLS.keys.include? environment - Bravo::URLS[environment][:wsaa] - end - - # Returns the right wsfe url for the specific environment - # @return [String] - # - def wsfe_url - raise 'Environment not sent to either :test or :production' unless Bravo::URLS.keys.include? environment - Bravo::URLS[environment][:wsfe] - end - - # Creates the data file name for a cuit number and the current day - # @return [String] - # - def todays_data_file_name - @todays_data_file ||= "/tmp/bravo_#{ Bravo.cuit }_#{ Time.new.strftime('%Y_%m_%d') }.yml" - end - end - end -end diff --git a/lib/bravo/authorization.rb b/lib/bravo/authorization.rb new file mode 100644 index 0000000..e47cb01 --- /dev/null +++ b/lib/bravo/authorization.rb @@ -0,0 +1,86 @@ +module Bravo + Credentials = Struct.new(:authorizations) do + def find(cuit) + authorizations.find { |authorization| authorization.cuit == cuit } + end + + def store(new_auth) + authorizations.reject! { |authorization| authorization.cuit == new_auth.cuit } + authorizations << new_auth + end + end + + class Authorization + attr_accessor :cuit, :pkey_path, :cert_path, :token, :sign, :created_at, :expires_at + + def initialize(cuit, pkey, cert) + @cuit = cuit + @pkey_path = validate_path(pkey) + @cert_path = validate_path(cert) + end + + # Returns the WSFE url for the specified environment + # @return [String] + # + def self.wsfe_url + raise 'environment not set' unless Bravo.environment + Bravo::URLS[Bravo.environment][:wsfe] + end + + # Returns the WSAA url for the specified environment + # @return [String] + # + def self.wsaa_url + raise 'environment not set' unless Bravo.environment + Bravo::URLS[Bravo.environment][:wsaa] + end + + def self.build(cuit, pkey_path, cert_path) + authorization = new(cuit, pkey_path, cert_path) + credentials.store(authorization) + authorization + end + + def self.credentials + @credentials ||= Credentials.new([]) + end + + def self.for(cuit) + credentials_for_cuit = credentials.find(cuit) + raise ::Bravo::MissingCredentials.new, "missing credentials for #{ cuit }" unless credentials_for_cuit + credentials_for_cuit + end + + def self.create(cuit:, pkey_path:, cert_path:) + authorization = build(cuit, pkey_path, cert_path) + authorization.authorize! + end + + def authorized? + !token.nil? && !sign.nil? && !expires_at.nil? && (expires_at > Time.new) + end + + def auth_hash + authorize! unless self.authorized? + { 'Cuit' => cuit, 'Sign' => sign, 'Token' => token } + end + + def authorize! + authorization_data = Wsaa.login(pkey_path, cert_path) + + self.token = authorization_data[:token] + self.sign = authorization_data[:sign] + self.expires_at = Time.parse(authorization_data[:expires_at]) + self.created_at = Time.parse(authorization_data[:created_at]) + + self + end + + private + + def validate_path(path) + raise(ArgumentError.new, "#{ path } does not exist") unless File.exist? path + path + end + end +end diff --git a/lib/bravo/bill.rb b/lib/bravo/bill.rb index 9a8f0e2..9266af0 100644 --- a/lib/bravo/bill.rb +++ b/lib/bravo/bill.rb @@ -1,44 +1,54 @@ # encoding: utf-8 +require 'bigdecimal' +require 'bigdecimal/util' + module Bravo # The main class in Bravo. Handles WSFE method interactions. # Subsequent implementations will be added here (maybe). # class Bill - # Returns the Savon::Client instance in charge of the interactions with WSFE API. - # (built on init) + # Returns the Savon::Client instance in charge of the interactions + # with WSFE API (built on init) # - attr_reader :client + attr_reader :client, :iva_sum, :total + + attr_accessor :net, :document_number, :iva_condition, :document_type, + :concept, :currency, :due_date, :aliciva_id, :date_from, :date_to, + :response, :invoice_type - attr_accessor :net, :document_number, :iva_condition, :document_type, :concept, - :currency, :due_date, :aliciva_id, :date_from, :date_to, :body, :response, - :invoice_type + # rubocop:disable Metrics/AbcSize + def initialize(cuit, attrs = {}) + @cuit = cuit - def initialize(attrs = {}) - opts = { wsdl: Bravo::AuthData.wsfe_url }.merge! Bravo.logger_options - @client ||= Savon.client(opts) - @body = { 'Auth' => Bravo::AuthData.auth_hash } + @client ||= Savon.client({ wsdl: Authorization.wsfe_url }.merge! Bravo.logger_options) + @net = attrs.fetch(:net, 0).to_d + @document_type = attrs.fetch(:document_type, Bravo.default_documento) + @currency = attrs.fetch(:currency, Bravo.default_moneda) + @concept = attrs.fetch(:concept, Bravo.default_concepto) @iva_condition = validate_iva_condition(attrs[:iva_condition]) - @net = attrs[:net] || 0 - @document_type = attrs[:document_type] || Bravo.default_documento - @currency = attrs[:currency] || Bravo.default_moneda - @concept = attrs[:concept] || Bravo.default_concepto @invoice_type = validate_invoice_type(attrs[:invoice_type]) end + # rubocop:enable Metrics/AbcSize + + # @private + def body + @body ||= { 'Auth' => Authorization.for(@cuit).auth_hash } + end # Searches the corresponding invoice type according to the combination of # the seller's IVA condition and the buyer's IVA condition # @return [String] the document type string # def bill_type - Bravo::BILL_TYPE[Bravo.own_iva_cond][iva_condition][invoice_type] + BILL_TYPE[Bravo.own_iva_cond][iva_condition][invoice_type] end # Calculates the total field for the invoice by adding # net and iva_sum. # @return [Float] the sum of both fields, or 0 if the net is 0. # - def total - @total = net.zero? ? 0 : net + iva_sum + def calculate_total + @total = net + calculate_iva_sum end # Calculates the corresponding iva sum. @@ -47,9 +57,8 @@ def total # # TODO: fix this # - def iva_sum - @iva_sum = net * applicable_iva_multiplier - @iva_sum.round(2) + def calculate_iva_sum + @iva_sum ||= (net * applicable_iva_multiplier).round(2) end # Files the authorization request to AFIP @@ -58,60 +67,47 @@ def iva_sum def authorize setup_bill response = client.call(:fecae_solicitar) do |soap| - # soap.namespaces['xmlns'] = 'http://ar.gov.afip.dif.FEV1/' soap.message body end setup_response(response.to_hash) - self.authorized? + authorized? end # Sets up the request body for the authorisation # @return [Hash] returns the request body as a hash # + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength def setup_bill - today = Time.new.strftime('%Y%m%d') - - fecaereq = { 'FeCAEReq' => { - 'FeCabReq' => Bravo::Bill.header(bill_type), - 'FeDetReq' => { - 'FECAEDetRequest' => { - 'Concepto' => Bravo::CONCEPTOS[concept], - 'DocTipo' => Bravo::DOCUMENTOS[document_type], - 'CbteFch' => today, - 'ImpTotConc' => 0.00, - 'MonId' => Bravo::MONEDAS[currency][:codigo], - 'MonCotiz' => 1, - 'ImpOpEx' => 0.00, - 'ImpTrib' => 0.00, - 'Iva' => { - 'AlicIva' => { - 'Id' => applicable_iva_code, - 'BaseImp' => net.round(2), - 'Importe' => iva_sum } } } } } } - - detail = fecaereq['FeCAEReq']['FeDetReq']['FECAEDetRequest'] - - detail['DocNro'] = document_number - detail['ImpNeto'] = net.to_f - detail['ImpIVA'] = iva_sum - detail['ImpTotal'] = total - detail['CbteDesde'] = detail['CbteHasta'] = Bravo::Reference.next_bill_number(bill_type) - - unless concept == 0 - detail.merge!({ 'FchServDesde' => date_from || today, - 'FchServHasta' => date_to || today, - 'FchVtoPago' => due_date || today }) - end - - body.merge!(fecaereq) + request = Request.new + request.header = Bill.header(bill_type) + request.concept = CONCEPTOS[concept] + request.document_type = DOCUMENTOS[document_type] + request.date = today + request.currency_id = MONEDAS[currency][:codigo] + request.iva_code = applicable_iva_code + request.net_amount = net.to_d + request.iva_amount = calculate_iva_sum + request.total = calculate_total + + request.from = request.to = Reference.next_bill_number(@cuit, bill_type) + request.document_number = document_number + + request.date_from = date_from || today + request.date_to = date_to || today + request.due_on = due_date || today + + body.merge!(request.to_hash) end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength # Returns the result of the authorization operation # @return [Boolean] the response result # def authorized? - !response.nil? && response.header_result == 'A' && response.detail_result == 'A' + response && response.header_result == 'A' && response.detail_result == 'A' end private @@ -121,50 +117,56 @@ class << self # @return [Hash] # def header(bill_type) - # todo sacado de la factura - { 'CantReg' => '1', 'CbteTipo' => bill_type, 'PtoVta' => Bravo.sale_point } + # toodo sacado de la factura + { 'CantReg' => '1', + 'CbteTipo' => bill_type, + 'PtoVta' => Bravo.sale_point + } end end # Response parser. Only works for the authorize method # @return [Struct] a struct with key-value pairs with the response values # + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize def setup_response(response) # TODO: turn this into an all-purpose Response class - - result = response[:fecae_solicitar_response][:fecae_solicitar_result] + result = response[:fecae_solicitar_response][:fecae_solicitar_result] 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 + 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!(request_detail.delete(:iva)['AlicIva'].underscore_keys.symbolize_keys) - request_detail.merge!(iva) + response_hash = { header_result: response_header.delete(:resultado), + authorized_on: response_header.delete(:fch_proceso), - response_hash = { :header_result => response_header.delete(:resultado), - :authorized_on => response_header.delete(:fch_proceso), - :detail_result => response_detail.delete(:resultado), - :cae_due_date => response_detail.delete(:cae_fch_vto), - :cae => response_detail.delete(:cae), - :iva_id => request_detail.delete(:id), - :iva_importe => request_detail.delete(:importe), - :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) - }.merge!(request_header).merge!(request_detail) + detail_result: response_detail.delete(:resultado), + cae_due_date: response_detail.delete(:cae_fch_vto), + cae: response_detail.delete(:cae), - keys, values = response_hash.to_a.transpose + iva_id: request_detail.delete(:id), + iva_importe: request_detail.delete(:importe), + 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) + }.merge!(request_header).merge!(request_detail) - self.response = (defined?(Struct::Response) ? Struct::Response : Struct.new('Response', *keys)).new(*values) + keys, values = response_hash.to_a.transpose + + self.response = Struct.new(*keys).new(*values) end + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize def applicable_iva - index = Bravo::APPLICABLE_IVA[Bravo.own_iva_cond][iva_condition] - Bravo::ALIC_IVA[index] + index = APPLICABLE_IVA[Bravo.own_iva_cond][iva_condition] + ALIC_IVA[index] end def applicable_iva_code @@ -176,22 +178,24 @@ def applicable_iva_multiplier end def validate_iva_condition(iva_cond) - valid_conditions = Bravo::BILL_TYPE[Bravo.own_iva_cond].keys + valid_conditions = BILL_TYPE[Bravo.own_iva_cond].keys if valid_conditions.include? iva_cond iva_cond else raise(NullOrInvalidAttribute.new, - "El valor de iva_condition debe estar incluído en #{ valid_conditions }") + "El valor de iva_condition debe estar incluído en #{ valid_conditions }") end end def validate_invoice_type(type) - if Bravo::BILL_TYPE_A.keys.include? type - type - else - raise(NullOrInvalidAttribute.new, "invoice_type debe estar incluido en \ + return type if BILL_TYPE_A.keys.include? type + + raise(NullOrInvalidAttribute.new, "invoice_type debe estar incluido en \ #{ Bravo::BILL_TYPE_A.keys }") - end + end + + def today + Time.new.strftime('%Y%m%d') end end end diff --git a/lib/bravo/constants.rb b/lib/bravo/constants.rb index 310d5c7..9129ebf 100644 --- a/lib/bravo/constants.rb +++ b/lib/bravo/constants.rb @@ -5,55 +5,56 @@ module Bravo # This constant contains the invoice types mappings between codes and names # used by WSFE. CBTE_TIPO = { - '01'=>'Factura A', - '02'=>'Nota de Débito A', - '03'=>'Nota de Crédito A', - '04'=>'Recibos A', - '05'=>'Notas de Venta al contado A', - '06'=>'Factura B', - '07'=>'Nota de Debito B', - '08'=>'Nota de Credito B', - '09'=>'Recibos B', - '10'=>'Notas de Venta al contado B', - '34'=>'Cbtes. A del Anexo I, Apartado A,inc.f),R.G.Nro. 1415', - '35'=>'Cbtes. B del Anexo I,Apartado A,inc. f),R.G. Nro. 1415', - '39'=>'Otros comprobantes A que cumplan con R.G.Nro. 1415', - '40'=>'Otros comprobantes B que cumplan con R.G.Nro. 1415', - '60'=>'Cta de Vta y Liquido prod. A', - '61'=>'Cta de Vta y Liquido prod. B', - '63'=>'Liquidacion A', - '64'=>'Liquidacion B' + '01' => 'Factura A', + '02' => 'Nota de Débito A', + '03' => 'Nota de Crédito A', + '04' => 'Recibos A', + '05' => 'Notas de Venta al contado A', + '06' => 'Factura B', + '07' => 'Nota de Debito B', + '08' => 'Nota de Credito B', + '09' => 'Recibos B', + '10' => 'Notas de Venta al contado B', + '34' => 'Cbtes. A del Anexo I, Apartado A,inc.f),R.G.Nro. 1415', + '35' => 'Cbtes. B del Anexo I,Apartado A,inc. f),R.G. Nro. 1415', + '39' => 'Otros comprobantes A que cumplan con R.G.Nro. 1415', + '40' => 'Otros comprobantes B que cumplan con R.G.Nro. 1415', + '60' => 'Cta de Vta y Liquido prod. A', + '61' => 'Cta de Vta y Liquido prod. B', + '63' => 'Liquidacion A', + '64' => 'Liquidacion B' } # Name to code mapping for Sale types. # - CONCEPTOS = { 'Productos'=>'01', 'Servicios'=>'02', 'Productos y Servicios'=>'03' } + CONCEPTOS = { 'Productos' => '01', 'Servicios' => '02', 'Productos y Servicios' => '03' } # Name to code mapping for types of documents. # DOCUMENTOS = { - 'CUIT'=>'80', - 'CUIL'=>'86', - 'CDI'=>'87', - 'LE'=>'89', - 'LC'=>'90', - 'CI Extranjera'=>'91', - 'en tramite'=>'92', - 'Acta Nacimiento'=>'93', - 'CI Bs. As. RNP'=>'95', - 'DNI'=>'96', - 'Pasaporte'=>'94', - 'Doc. (Otro)'=>'99' } + 'CUIT' => '80', + 'CUIL' => '86', + 'CDI' => '87', + 'LE' => '89', + 'LC' => '90', + 'CI Extranjera' => '91', + 'en tramite' => '92', + 'Acta Nacimiento' => '93', + 'CI Bs. As. RNP' => '95', + 'DNI' => '96', + 'Pasaporte' => '94', + 'Doc. (Otro)' => '99' + } # Currency code and names hash identified by a symbol # MONEDAS = { - :peso => { :codigo => 'PES', :nombre =>'Pesos Argentinos' }, - :dolar => { :codigo => 'DOL', :nombre =>'Dolar Estadounidense' }, - :real => { :codigo => '012', :nombre =>'Real' }, - :euro => { :codigo => '060', :nombre =>'Euro' }, - :oro => { :codigo => '049', :nombre =>'Gramos de Oro Fino' } } - + peso: { codigo: 'PES', nombre: 'Pesos Argentinos' }, + dolar: { codigo: 'DOL', nombre: 'Dolar Estadounidense' }, + real: { codigo: '012', nombre: 'Real' }, + euro: { codigo: '060', nombre: 'Euro' }, + oro: { codigo: '049', nombre: 'Gramos de Oro Fino' } + } # Tax percentage and codes according to each iva combination # @@ -62,7 +63,7 @@ module Bravo # Applicable tax according to buyer and seller's iva condition. # APPLICABLE_IVA = { - :responsable_inscripto => { + responsable_inscripto: { responsable_inscripto: 02, consumidor_final: 00, exento: 00, @@ -73,16 +74,18 @@ module Bravo # This hash keeps the codes for A document types by operation # BILL_TYPE_A = { - :invoice => '01', - :debit => '02', - :credit => '03' } + invoice: '01', + debit: '02', + credit: '03' + } - # This hash keeps the codes for A document types by operation + # This hash keeps the codes for B document types by operation # BILL_TYPE_B = { - :invoice => '06', - :debit => '07', - :credit => '08' } + invoice: '06', + debit: '07', + credit: '08' + } # This hash keeps the different buyer and invoice type mapping corresponding to # the seller's iva condition and invoice kind. @@ -91,18 +94,20 @@ module Bravo # `BILL_TYPE[:responsable_inscripto][:responsable_inscripto][:invoice]` #=> '01' # BILL_TYPE = { - :responsable_inscripto => { - :responsable_inscripto => BILL_TYPE_A, - :consumidor_final => BILL_TYPE_B, - :exento => BILL_TYPE_B, - :responsable_monotributo => BILL_TYPE_B } } + responsable_inscripto: { + responsable_inscripto: BILL_TYPE_A, + consumidor_final: BILL_TYPE_B, + exento: BILL_TYPE_B, + responsable_monotributo: BILL_TYPE_B } + } # This hash keeps the set of urls for wsaa and wsfe for production and testing envs # URLS = { - :test => { :wsaa => 'https://wsaahomo.afip.gov.ar/ws/services/LoginCms', - :wsfe => 'https://wswhomo.afip.gov.ar/wsfev1/service.asmx?WSDL' }, + test: { wsaa: 'https://wsaahomo.afip.gov.ar/ws/services/LoginCms', + wsfe: 'https://wswhomo.afip.gov.ar/wsfev1/service.asmx?WSDL' }, - :production => { :wsaa => 'https://wsaa.afip.gov.ar/ws/services/LoginCms', - :wsfe => 'https://servicios1.afip.gov.ar/wsfev1/service.asmx' } } + production: { wsaa: 'https://wsaa.afip.gov.ar/ws/services/LoginCms', + wsfe: 'https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL' } + } end diff --git a/lib/bravo/core_ext/string.rb b/lib/bravo/core_ext/string.rb index 5bc8bbc..42ee333 100644 --- a/lib/bravo/core_ext/string.rb +++ b/lib/bravo/core_ext/string.rb @@ -4,12 +4,12 @@ class String # Stolen from activesupport/lib/active_support/inflector/methods.rb, line 48 # def underscore - word = self.to_s.dup + word = to_s.dup word.gsub!(/::/, '/') - word.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2') - word.gsub!(/([a-z\d])([A-Z])/,'\1_\2') + word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + word.gsub!(/([a-z\d])([A-Z])/, '\1_\2') word.tr!('-', '_') word.downcase! word end -end \ No newline at end of file +end diff --git a/lib/bravo/reference.rb b/lib/bravo/reference.rb index 346cc41..e6e67f3 100644 --- a/lib/bravo/reference.rb +++ b/lib/bravo/reference.rb @@ -5,11 +5,12 @@ class Reference # Fetches the number for the next bill to be issued # @return [Integer] the number for the next bill # - def self.next_bill_number(cbte_type) + def self.next_bill_number(cuit, cbte_type) set_client resp = @client.call(:fe_comp_ultimo_autorizado) do |soap| # soap.namespaces['xmlns'] = 'http://ar.gov.afip.dif.FEV1/' - soap.message 'Auth' => Bravo::AuthData.auth_hash, 'PtoVta' => Bravo.sale_point, 'CbteTipo' => cbte_type + soap.message 'Auth' => Authorization.for(cuit).auth_hash, 'PtoVta' => Bravo.sale_point, + 'CbteTipo' => cbte_type end resp.to_hash[:fe_comp_ultimo_autorizado_response][:fe_comp_ultimo_autorizado_result][:cbte_nro].to_i + 1 @@ -18,10 +19,10 @@ def self.next_bill_number(cbte_type) # Fetches the possible document codes and names # @return [Hash] # - def self.get_custom(operation) + def self.get_custom(cuit, operation) set_client resp = @client.call(operation) do |soap| - soap.message 'Auth' => Bravo::AuthData.auth_hash + soap.message 'Auth' => Authorization.for(cuit).auth_hash end resp.to_hash end @@ -30,7 +31,7 @@ def self.get_custom(operation) # # def self.set_client - opts = { wsdl: Bravo::AuthData.wsfe_url }.merge! Bravo.logger_options + opts = { wsdl: Authorization.wsfe_url }.merge! Bravo.logger_options @client = Savon.client(opts) end end diff --git a/lib/bravo/request.rb b/lib/bravo/request.rb new file mode 100644 index 0000000..7569b2b --- /dev/null +++ b/lib/bravo/request.rb @@ -0,0 +1,48 @@ +module Bravo + class Request + attr_accessor :concept, :document_type, :date, :currency_id, :iva_code, + :net_amount, :iva_amount, :document_number, :total, :from, :to, + :date_from, :date_to, :due_on, :header + + def to_hash + { 'FeCAEReq' => { 'FeCabReq' => header, 'FeDetReq' => build_details } } + end + + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength + def build_details + details = { + 'FECAEDetRequest' => { + 'Concepto' => concept, + 'DocTipo' => document_type, + 'CbteFch' => date, + 'ImpTotConc' => 0.00, + 'MonId' => currency_id, + 'MonCotiz' => 1, + 'ImpOpEx' => 0.00, + 'ImpTrib' => 0.00, + 'DocNro' => document_number, + 'ImpNeto' => net_amount, + 'ImpIVA' => iva_amount, + 'ImpTotal' => total, + 'CbteDesde' => from, + 'CbteHasta' => to, + 'FchServDesde' => date_from, + 'FchServHasta' => date_to, + 'FchVtoPago' => due_on, + + 'Iva' => { + 'AlicIva' => { + 'Id' => iva_code, + 'BaseImp' => net_amount, + 'Importe' => iva_amount + } + } + } + } + details.reject { |_k, v| v.nil? } + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + end +end diff --git a/lib/bravo/wsaa.rb b/lib/bravo/wsaa.rb index ad56c50..b285bf3 100644 --- a/lib/bravo/wsaa.rb +++ b/lib/bravo/wsaa.rb @@ -7,24 +7,28 @@ class Wsaa # Main method for authentication and authorization. # When successful, produces the yaml file with auth data. # - def self.login + def self.login(pkey_path, cert_path) tra = build_tra - cms = build_cms(tra) + cms = build_cms(tra, pkey_path, cert_path) req = build_request(cms) - auth = call_wsaa(req) + call_wsaa(req) + end - write_yaml(auth) + def self.login_to_file(filename, pkey_path, cert_path) + auth = login(pkey_path, cert_path) + write_yaml(auth, filename) + auth end - protected # Builds the xml for the 'Ticket de Requerimiento de Acceso' # @return [String] containing the request body # + # rubocop:disable Metrics/MethodLength def self.build_tra - @now = (Time.now) - 120 - @from = @now.strftime('%FT%T%:z') - @to = (@now + ((12*60*60))).strftime('%FT%T%:z') - @id = @now.strftime('%s') + now = (Time.now) - 120 + @from = now.strftime('%FT%T%:z') + @to = (now + ((12 * 60 * 60))).strftime('%FT%T%:z') + @id = now.strftime('%s') tra = <<-EOF @@ -36,24 +40,24 @@ def self.build_tra wsfe EOF - return tra + tra end - + # rubocop:enable Metrics/MethodLength # Builds the CMS # @return [String] cms # - def self.build_cms(tra) - cms = `echo '#{ tra }' | - #{ Bravo.openssl_bin } cms -sign -in /dev/stdin -signer #{ Bravo.cert } -inkey #{ Bravo.pkey } -nodetach \ - -outform der | + def self.build_cms(tra, pkey_path, cert_path) + `echo '#{ tra }' | + #{ Bravo.openssl_bin } cms -sign -in /dev/stdin -signer #{ cert_path } -inkey #{ pkey_path } \ + -nodetach -outform der | #{ Bravo.openssl_bin } base64 -e` - return cms end # Builds the CMS request to log in to the server # @return [String] the cms body # def self.build_request(cms) + # rubocop:disable Metrics/LineLength request = <<-XML @@ -66,31 +70,35 @@ def self.build_request(cms) XML - return request + request end + # rubocop:enable Metrics/LineLength # Calls the WSAA with the request built by build_request # @return [Array] with the token and signature # + # rubocop:disable Metrics/AbcSize def self.call_wsaa(req) + # XXX: a request made too soon after a successful one throws an error. deal with it response = `echo '#{ req }' | - curl -k -s -H 'Content-Type: application/soap+xml; action=""' -d @- #{ Bravo::AuthData.wsaa_url }` + curl -k -s -H 'Content-Type: application/soap+xml; action=""' -d @- #{ Authorization.wsaa_url }` - response = CGI::unescapeHTML(response) - token = response.scan(/\(.+)\<\/token\>/).first.first - sign = response.scan(/\(.+)\<\/sign\>/).first.first - return [token, sign] + response = CGI.unescapeHTML(response) + puts response + # ns1:coe.alreadyAuthenticated grepear esto para evitar errores + token = response.scan(%r{\(.+)\<\/token\>}).flatten.first + sign = response.scan(%r{\(.+)\<\/sign\>}).flatten.first + created_at = response.scan(%r{\(.+)\<\/generationTime\>}).flatten.first + expires_at = response.scan(%r{\(.+)\<\/expirationTime\>}).flatten.first + + { token: token, sign: sign, created_at: created_at, expires_at: expires_at } end + # rubocop:enable Metrics/AbcSize # Writes the token and signature to a YAML file in the /tmp directory # - def self.write_yaml(certs) - yml = <<-YML -token: #{certs[0]} -sign: #{certs[1]} -YML - `echo '#{ yml }' > /tmp/bravo_#{ Bravo.cuit }_#{ Time.new.strftime('%Y_%m_%d') }.yml` + def self.write_yaml(credentials, filename) + File.write(filename, credentials.to_yaml) end - end end diff --git a/spec/bravo/auth_data_spec.rb b/spec/bravo/auth_data_spec.rb deleted file mode 100644 index 3ba4784..0000000 --- a/spec/bravo/auth_data_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') - -describe 'AuthData' do - describe '.fetch' do - it 'creates constants for todays data' do - Bravo.constants.should_not include(:TOKEN, :SIGN) - - Bravo::AuthData.fetch - - Bravo.constants.should include(:TOKEN, :SIGN) - end - end -end \ No newline at end of file diff --git a/spec/bravo/authorization_spec.rb b/spec/bravo/authorization_spec.rb new file mode 100644 index 0000000..1a2b82f --- /dev/null +++ b/spec/bravo/authorization_spec.rb @@ -0,0 +1,118 @@ +require 'spec_helper' + +module Bravo + describe Authorization do + context 'on initialize' do + context 'with bad path' do + let(:subject) { described_class.new('20287740027', 'bad/pkey/path', 'bad/cert/path') } + + it 'requires cuit, pkey_path and cert_path' do + expect { subject }.to raise_exception(ArgumentError) + end + end + + context 'with correct attributes' do + let(:authorization) do + described_class.new('20287740027', 'spec/fixtures/certs/pkey', 'spec/fixtures/certs/cert.crt') + end + + it 'returns a valid instance' do + expect(authorization).to be_a(described_class) + end + + it 'sets cuit, pkey_path and cert_path instance variables' do + expect(authorization.cuit).to eq '20287740027' + expect(authorization.pkey_path).to eq "spec/fixtures/certs/pkey" + expect(authorization.cert_path).to eq "spec/fixtures/certs/cert.crt" + end + end + end + + context 'when building a new set of credentials' do + it 'stores the new auth in the authorizations instance variable' do + expect do + described_class.build('20287740027', 'spec/fixtures/certs/pkey', 'spec/fixtures/certs/cert.crt') + end.to change(described_class.credentials.authorizations, :count).by(1) + end + end + + context 'when requested for credentials for a cuit' do + context 'when there are none for that cuit' do + before do + described_class.credentials.authorizations = [] + end + + it 'raises an error' do + expect { described_class.for('some_cuit') }.to raise_exception(::Bravo::MissingCredentials, + 'missing credentials for some_cuit') + end + end + + context 'when there are credentials for that cuit' do + let(:authorization) { valid_authorization } + + it 'returns the authorization' do + expect(described_class.for(authorization.cuit)).to eq(authorization) + end + end + end + + context 'a valid instance' do + subject(:authorization) { valid_authorization } + + context 'when checking if its authorized' do + it 'returns false for unauthorized credentials' do + expect(authorization.authorized?).to be_falsey + end + end + + context 'when calling the login method' do + it 'sets the login data attributes', vcr: { cassette_name: 'valid_login' } do + expect(authorization.authorize!).to be_truthy + + expect(authorization.token).to be_truthy + expect(authorization.sign).to be_truthy + expect(authorization.created_at).to be_truthy + expect(authorization.expires_at).to be_truthy + + expect(authorization.authorized?).to be_truthy + end + end + end + + context 'when credentials are current' do + subject(:authorization) { described_class.for('20287740027') } + + before do + timestamp = Time.new + + described_class.build('20287740027', 'spec/fixtures/certs/pkey', 'spec/fixtures/certs/cert.crt') + + subject.token = 'token' + subject.sign = 'sign' + subject.expires_at = timestamp + 3600 + subject.created_at = timestamp - 7200 + + described_class.credentials.store subject + end + + it 'builds the auth hash from memory' do + expect { subject.auth_hash }.not_to change(subject, :created_at) + end + end + + context 'when credentials are expired' do + subject(:authorization) do + described_class.build('20287740027', 'spec/fixtures/certs/pkey', 'spec/fixtures/certs/cert.crt') + end + + it 'renews the credentials', vcr: { cassette_name: 'valid_login' }do + expect { subject.auth_hash }.to change(subject, :created_at) + end + end + + def valid_authorization + described_class.build('20287740027', 'spec/fixtures/certs/pkey', 'spec/fixtures/certs/cert.crt') + end + end +end diff --git a/spec/bravo/bill_spec.rb b/spec/bravo/bill_spec.rb index 058637a..7c530c8 100644 --- a/spec/bravo/bill_spec.rb +++ b/spec/bravo/bill_spec.rb @@ -1,119 +1,136 @@ # encoding: utf-8 require 'spec_helper' -describe 'Bill' do - let(:bill) { @bill = Bravo::Bill.new(iva_condition: :consumidor_final, invoice_type: :invoice) } - - describe '.header' do - it 'sets up the header hash' do - @header = Bravo::Bill.header(0) - @header.size.should == 3 - ['CantReg', 'CbteTipo', 'PtoVta'].each do |key| - @header.has_key?(key).should == true +module Bravo + describe Bill do + let(:bill) { Bravo::Bill.new('20287740027', iva_condition: :consumidor_final, invoice_type: :invoice) } + + describe '.header' do + subject(:header) { described_class.header(0) } + it 'sets up the header hash' do + expect(header.size).to be 3 + %w[CantReg CbteTipo PtoVta].each do |key| + expect(header.key?(key)).to be_truthy + end end end - end - describe '.initialize' do - it 'applies Bravos defaults' do - bill.client.class.name.should == 'Savon::Client' + describe '.initialize' do + it 'applies Bravos defaults' do + expect(bill.client).to be_a Savon::Client - ['Token', 'Sign', 'Cuit'].each do |key| - bill.body['Auth'][key].should_not == nil + expect(bill.document_type).to be Bravo.default_documento + expect(bill.currency).to be Bravo.default_moneda end - - bill.document_type.should == Bravo.default_documento - bill.currency.should == Bravo.default_moneda end - end - describe '#bill_type' do - before { bill.invoice_type = :invoice } - it 'returns the bill type for Responsable Inscripto' do - bill.iva_condition = :responsable_inscripto + describe '.body' do + before do + create_auth + end - bill.bill_type.should == '01' + it 'sets up a body if there is none', vcr: { cassette_name: 'valid_login' } do + %w[Token Sign Cuit].each do |key| + expect(bill.body['Auth'].fetch(key, nil)).not_to be_nil + end + end end - it 'returns the bill type for Consumidor Final' do - bill.iva_condition = :consumidor_final + describe '#bill_type' do + before { bill.invoice_type = :invoice } + it 'returns the bill type for Responsable Inscripto' do + bill.iva_condition = :responsable_inscripto - bill.bill_type.should == '06' - end - end + expect(bill.bill_type).to eq '01' + end - describe '#iva_sum and #total' do - it 'calculate the IVA array values' do - bill.iva_condition = :responsable_inscripto - bill.currency = :peso - bill.net = 100.89 - bill.aliciva_id = 2 + it 'returns the bill type for Consumidor Final' do + bill.iva_condition = :consumidor_final - bill.iva_sum.should be_within(0.005).of(21.19) - bill.total.should be_within(0.005).of(122.08) + expect(bill.bill_type).to eq '06' + end end - end - describe '#setup_bill' do - before do - bill.net = 100 - bill.aliciva_id = 2 - bill.document_number = '30710151543' - bill.iva_condition = :responsable_inscripto - bill.concept = 'Servicios' + describe '#iva_sum and #total' do + it 'calculates the IVA array values' do + bill.iva_condition = :responsable_inscripto + bill.currency = :peso + bill.net = 100.89 + bill.aliciva_id = 2 + + expect(bill.calculate_iva_sum).to be_within(0.005).of(21.19) + expect(bill.calculate_total).to be_within(0.005).of(122.08) + end end - it 'uses today dates when due and service dates are ommitted', vcr: { cassette_name: 'setup_bill_ommitted_date' } do - bill.setup_bill + describe '#setup_bill' do + before do + bill.net = 100 + bill.aliciva_id = 2 + bill.document_number = '30710151543' + bill.iva_condition = :responsable_inscripto + bill.concept = 'Servicios' + end - detail = bill.body['FeCAEReq']['FeDetReq']['FECAEDetRequest'] + it 'uses today dates when due and service dates are null', + vcr: { cassette_name: 'setup_bill_ommitted_date' } do + bill.setup_bill - detail['FchServDesde'].should == Time.new.strftime('%Y%m%d') - detail['FchServHasta'].should == Time.new.strftime('%Y%m%d') - detail['FchVtoPago'].should == Time.new.strftime('%Y%m%d') - end + detail = bill.body['FeCAEReq']['FeDetReq']['FECAEDetRequest'] - it 'uses given due and service dates', vcr: { cassette_name: 'setup_bill_given_date' } do - bill.due_date = Date.new(2011, 12, 10).strftime('%Y%m%d') - bill.date_from = Date.new(2011, 11, 01).strftime('%Y%m%d') - bill.date_to = Date.new(2011, 11, 30).strftime('%Y%m%d') + expect(detail['FchServDesde']).to eq Time.new.strftime('%Y%m%d') + expect(detail['FchServHasta']).to eq Time.new.strftime('%Y%m%d') + expect(detail['FchVtoPago']).to eq Time.new.strftime('%Y%m%d') + end - bill.setup_bill + it 'uses given due and service dates', vcr: { cassette_name: 'setup_bill_given_date' } do + bill.due_date = Date.new(2011, 12, 10).strftime('%Y%m%d') + bill.date_from = Date.new(2011, 11, 01).strftime('%Y%m%d') + bill.date_to = Date.new(2011, 11, 30).strftime('%Y%m%d') - detail = bill.body['FeCAEReq']['FeDetReq']['FECAEDetRequest'] + bill.setup_bill - detail['FchServDesde'].should == '20111101' - detail['FchServHasta'].should == '20111130' - detail['FchVtoPago'].should == '20111210' + detail = bill.body['FeCAEReq']['FeDetReq']['FECAEDetRequest'] + + expect(detail['FchServDesde']).to eq '20111101' + expect(detail['FchServHasta']).to eq '20111130' + expect(detail['FchVtoPago']).to eq '20111210' + end end - end - describe '#authorize' do - describe 'for facturas' do - Bravo::BILL_TYPE[Bravo.own_iva_cond].keys.each do |target_iva_cond| - describe "issued to #{ target_iva_cond.to_s }" do - Bravo::BILL_TYPE[Bravo.own_iva_cond][target_iva_cond].keys.each do |bill_type| - vcr_options = { cassette_name: "#{ target_iva_cond.to_s }_and_#{ bill_type }" } - it "authorizes bill type #{ bill_type }", vcr: vcr_options do - bill.net = 10000.88 - bill.aliciva_id = 2 - bill.document_number = '30710151543' - bill.iva_condition = target_iva_cond - bill.concept = 'Servicios' - bill.invoice_type = bill_type - - bill.authorized?.should == false - bill.authorize.should == true - bill.authorized?.should == true - - response = bill.response - - response.length.should == 28 - response.cae.length.should == 14 + describe '#authorize' do + describe 'for invoices' do + Bravo::BILL_TYPE[Bravo.own_iva_cond].keys.each do |target_iva_cond| + describe "issued to #{ target_iva_cond }" do + Bravo::BILL_TYPE[Bravo.own_iva_cond][target_iva_cond].keys.each do |bill_type| + vcr_options = { cassette_name: "#{ target_iva_cond }_and_#{ bill_type }" } + it "authorizes bill type #{ bill_type }", vcr: vcr_options do + bill.net = 10_000.88 + bill.aliciva_id = 2 + bill.document_number = '30710151543' + bill.iva_condition = target_iva_cond + bill.concept = 'Servicios' + bill.invoice_type = bill_type + + expect(bill.authorized?).to be_falsey + + expect(bill.authorize).to be_truthy + expect(bill.authorized?).to be_truthy + + response = bill.response + + expect(response.length).to eql 28 + expect(response.cae.length).to eql 14 + end end end end end end + + def create_auth + Authorization.create(cuit: '20287740027', pkey_path: 'spec/fixtures/certs/pkey', + cert_path: 'spec/fixtures/certs/cert.crt') + end end end diff --git a/spec/bravo/reference_spec.rb b/spec/bravo/reference_spec.rb index 7942975..2e0833b 100644 --- a/spec/bravo/reference_spec.rb +++ b/spec/bravo/reference_spec.rb @@ -9,4 +9,4 @@ # # # end -end \ No newline at end of file +end diff --git a/spec/bravo/wsaa_spec.rb b/spec/bravo/wsaa_spec.rb index ffb97a9..6e263bc 100644 --- a/spec/bravo/wsaa_spec.rb +++ b/spec/bravo/wsaa_spec.rb @@ -4,7 +4,7 @@ before do @now = (Time.now) - 120 @from = @now.strftime('%FT%T%:z') - @to = (@now + ((12*60*60))).strftime('%FT%T%:z') + @to = (@now + ((12 * 60 * 60))).strftime('%FT%T%:z') @id = @now.strftime('%s') @tra = <<-EOF @@ -21,19 +21,21 @@ describe '.build_tra' do it 'sets the body for the ticket request' do - Bravo::Wsaa.build_tra.should == @tra + expect(Bravo::Wsaa.build_tra).to eq @tra end end describe '.build_cms' do - it 'returns the cms with the tra in it' do - pending 'find a proper way to stub openssl' + pending 'returns the cms with the tra in it' do + expect(false).to be_truthy end end describe '.login' do - xit 'should work', vcr: { cassette_name: 'login' } do - Bravo::Wsaa.login.should be_true + it 'writes the auth file', vcr: { cassette_name: 'login' } do + expect(File).to receive(:write) + Bravo::Wsaa.login_to_file('/tmp/bravo_test.yml', 'spec/fixtures/certs/pkey', + 'spec/fixtures/certs/cert.crt') end end end diff --git a/spec/fixtures/certs/cert.crt b/spec/fixtures/certs/cert.crt index 130a426..f3f901d 100755 --- a/spec/fixtures/certs/cert.crt +++ b/spec/fixtures/certs/cert.crt @@ -1,22 +1,23 @@ -----BEGIN CERTIFICATE----- -MIIDpjCCAo6gAwIBAgIIMKwSjJcsnxgwDQYJKoZIhvcNAQEFBQAwQzElMCMGA1UE +MIIDqDCCApCgAwIBAgIIAbB+njdEwWUwDQYJKoZIhvcNAQEFBQAwQzElMCMGA1UE AwwcQUZJUCBUZXN0aW5nIENvbXB1dGFkb3JlcyBDQTENMAsGA1UECgwEQUZJUDEL -MAkGA1UEBhMCQVIwHhcNMTMwMjAxMTMwNDA4WhcNMTUxMDI5MTMwNDA4WjBEMQ0w -CwYDVQQDDARMZWFuMRkwFwYDVQQFExBDVUlUIDIwMjg3NzQwMDI3MQswCQYDVQQK -DAJObzELMAkGA1UEBhMCQVIwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAKQE -xPczGh5ou0LAUWa7SnScc+ojF0cDYyYXeRRNZ3r69IRjfmNjQQ9EczW+PC1h/HNv -Z2ZAu0401uO0ySAF4ncShUrwwNj4da2B9WXEcTMZ8DixApwevTQ2AKQXJ1XOzGGJ -fhyxbexDrF6EOjBno30h4NfCMQjzFqOeeXhryEwnAgMBAAGjggEfMIIBGzAMBgNV -HRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIF4DAdBgNVHQ4EFgQUfmtL+JcFml+AhNjA -X9GN4QDCAM0wHwYDVR0jBBgwFoAURHTutJwm31bhwQ3rVwuQGTY9lgEwgboGA1Ud -IASBsjCBrzCBrAYOKwYBBAGBu2MBAgECAQEwgZkwgZYGCCsGAQUFBwICMIGJHoGG -AEMAZQByAHQAaQBmAGkAYwBhAGQAbwAgAHAAYQByAGEAIABjAG8AbQBwAHUAdABh -AGQAbwByAGUAcwAgAHMAbwBsAG8AIAB2AGEAbABpAGQAbwAgAGUAbgAgAGUAbgB0 -AG8AcgBuAG8AcwAgAGQAZQAgAGQAZQBzAGEAcgByAG8AbABsAG8wDQYJKoZIhvcN -AQEFBQADggEBAEW7IiTcq58vccBhfxfu2eV1UTV7/It5royRwrXwIeNqoC76KoB8 -XsX3GeV+INlNyqDZ1fibfbCLsT7Vm3lkCIHX3ELKjm/hQSO/m0rIdMg4DknwsFYw -gUOjRsXAsChcCMiXgKnv080PehtvOa2AviLabp4Db6N9ghLMTT6gHkumqu8joKY5 -Qkldrf+ENK5SDE+oDdU11+eMykx2rRAHg2riffUEWaPnlu+RVThsokiz3ieDqY51 -i+KBfNWrnCvgh+Iz+5GosMH4neI9NXtPZo1ZrFef8aV/I/vnIBUD3/jHIUnyK62d -rPNCRze2CLIc4qT9YeUpD6NbbQ0giSPZ8Dc= ------END CERTIFICATE----- \ No newline at end of file +MAkGA1UEBhMCQVIwHhcNMTUxMTA1MTYyOTQyWhcNMTgwODAxMTYyOTQyWjBGMQ0w +CwYDVQQDDARsZWFuMRkwFwYDVQQFExBDVUlUIDIwMjg3NzQwMDI3MQ0wCwYDVQQK +DARsZWFuMQswCQYDVQQGEwJBUjCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA +pATE9zMaHmi7QsBRZrtKdJxz6iMXRwNjJhd5FE1nevr0hGN+Y2NBD0RzNb48LWH8 +c29nZkC7TjTW47TJIAXidxKFSvDA2Ph1rYH1ZcRxMxnwOLECnB69NDYApBcnVc7M +YYl+HLFt7EOsXoQ6MGejfSHg18IxCPMWo555eGvITCcCAwEAAaOCAR8wggEbMAwG +A1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgXgMB0GA1UdDgQWBBR+a0v4lwWaX4CE +2MBf0Y3hAMIAzTAfBgNVHSMEGDAWgBREdO60nCbfVuHBDetXC5AZNj2WATCBugYD +VR0gBIGyMIGvMIGsBg4rBgEEAYG7YwECAQIBATCBmTCBlgYIKwYBBQUHAgIwgYke +gYYAQwBlAHIAdABpAGYAaQBjAGEAZABvACAAcABhAHIAYQAgAGMAbwBtAHAAdQB0 +AGEAZABvAHIAZQBzACAAcwBvAGwAbwAgAHYAYQBsAGkAZABvACAAZQBuACAAZQBu +AHQAbwByAG4AbwBzACAAZABlACAAZABlAHMAYQByAHIAbwBsAGwAbzANBgkqhkiG +9w0BAQUFAAOCAQEAKuoAl73cac55OcPNHTNCuBhAkKg4mXo5YLCvuqVRw5Icnm1s +VwloGLSpJnz5dhanoDnKF1XP5aBaovl5brD8mCaALHQhpf5oGDHO4cZkdsQ1kEhL +Rjk6hxq2q2a/JZhnBc9M2ckSkuBOfPdLIMIgR8HebwheQW9oEcm0o1p71Dk8cXpX +5z77Ce5ONnS+EKylmZ1bIT95C7eY0WLadg8qTOq4D59GJvho0Z6//r+cXg4u+UAz +KDPZMto9rbxYiUr5G5LteSVIrqT2SkYmjM41i3URZRD0aKxsuloChRN0K2tsx5Qk +w+pbIKcQOEah1sHvgLpRKJDZxCDp7oU5cLgDuA== +-----END CERTIFICATE----- + diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index cd50de1..e25f4bc 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,46 +1,25 @@ -$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) require 'bravo' require 'rspec' require 'vcr' -require 'simplecov' -# SimpleCov.start - -begin - require 'debugger' -rescue LoadError -end +require 'byebug' VCR.configure do |c| c.cassette_library_dir = 'spec/fixtures/vcr_cassettes' - c.hook_into :fakeweb + c.hook_into :webmock c.configure_rspec_metadata! end RSpec.configure do |config| - config.treat_symbols_as_metadata_keys_with_true_values = true - config.filter_run :focus => true + config.filter_run focus: true config.run_all_when_everything_filtered = true end -Bravo.pkey = 'spec/fixtures/certs/pkey' -Bravo.cert = 'spec/fixtures/certs/cert.crt' -Bravo.cuit = ENV['CUIT'] || '20287740027' Bravo.sale_point = ENV['SALE'] || '0002' Bravo.default_concepto = 'Productos y Servicios' Bravo.default_documento = 'CUIT' Bravo.default_moneda = :peso Bravo.own_iva_cond = :responsable_inscripto -# Bravo.logger = { log: true, level: :critical } -Bravo.openssl_bin = ENV["TRAVIS"] ? 'openssl' : '/usr/local/Cellar/openssl/1.0.1e/bin/openssl' -Bravo::AuthData.environment = :test - -# TODO: refactor into actual validations -unless Bravo.cuit - raise(Bravo::NullOrInvalidAttribute.new, 'Please set CUIT env variable.') -end - -[Bravo.pkey, Bravo.cert].each do |file| - unless File.exists?("#{ file }") - raise(Bravo::MissingCertificate.new, "No existe #{ file }") - end -end +Bravo.logger = { log: false, level: :info } +Bravo.openssl_bin = ENV["TRAVIS"] ? 'openssl' : '/usr/local/Cellar/openssl/1.0.2d_1/bin/openssl' +Bravo.environment = :test