├── log └── .keep ├── tmp └── .keep ├── vendor └── .keep ├── db ├── test.sqlite3 ├── development.sqlite3 ├── migrate │ ├── 20190114174946_add_logo_to_entities.rb │ ├── 20190329172313_add_note_to_invoices.rb │ ├── 20190318183753_add_logo_url_to_invoice.rb │ ├── 20210417205208_add_index_to_invoice.rb │ ├── 20180418170134_add_auth_token_to_entity.rb │ ├── 20190430183512_add_recipient_to_invoices.rb │ ├── 20180418171010_add_null_constraint_to_entity.rb │ ├── 20180417215413_add_bill_type_id_to_invoice.rb │ ├── 20190109203507_update_column_of_invoice_item.rb │ ├── 20180423192212_rename_invoice_item_name_to_description.rb │ ├── 20210907143040_add_index_to_entity.rb │ ├── 20190925142300_add_optional_columns_to_invoices.rb │ ├── 20181127190744_add_token_to_invoices.rb │ ├── 20190215205550_add_columns_to_entities.rb │ ├── 20180327215733_create_entities.rb │ ├── 20180417184833_create_invoices.rb │ ├── 20180423200117_add_null_constraints_to_entity.rb │ ├── 20200814175226_create_associated_invoices.rb │ ├── 20190110114135_add_iva_aliquot_to_invoice_items.rb │ ├── 20210417192854_create_afip_requests.rb │ ├── 20180417191443_create_invoice_items.rb │ └── 20190227013709_change_iva_aliquot_in_invoice_items.rb └── seeds.rb ├── lib ├── tasks │ ├── .keep │ ├── invoices_load_recipients.rake │ ├── fix_database.rake │ └── credentials_generator.rake └── capistrano │ └── tasks │ └── .keep ├── .ruby-version ├── app ├── models │ ├── concerns │ │ ├── .keep │ │ ├── invoiceable.rb │ │ └── encryptable.rb │ ├── application_record.rb │ ├── associated_invoice.rb │ ├── afip_request.rb │ ├── entity.rb │ ├── invoice_item.rb │ └── invoice.rb ├── controllers │ ├── concerns │ │ └── .keep │ ├── admin_controller.rb │ ├── v1 │ │ ├── afip_people_controller.rb │ │ ├── static_controller.rb │ │ └── entities_controller.rb │ └── application_controller.rb ├── services │ ├── static_resource │ │ ├── currencies.rb │ │ ├── document_types.rb │ │ ├── optionals.rb │ │ ├── tax_types.rb │ │ ├── concept_types.rb │ │ ├── sale_points.rb │ │ ├── iva_types.rb │ │ ├── bill_types.rb │ │ └── base.rb │ ├── auth │ │ ├── token_validator.rb │ │ └── encryptor.rb │ ├── afip │ │ ├── people_service.rb │ │ ├── invoices_service.rb │ │ ├── entity_data_generator.rb │ │ ├── person.rb │ │ └── manager.rb │ ├── loggers │ │ ├── afip_connection.rb │ │ └── invoice.rb │ └── invoice │ │ ├── queue.rb │ │ ├── recipient_loader.rb │ │ ├── generator │ │ └── result.rb │ │ ├── data_formatter.rb │ │ ├── schema.rb │ │ ├── builder.rb │ │ ├── finder.rb │ │ └── creator.rb ├── errors │ └── afip │ │ ├── timeout_error.rb │ │ ├── base_error.rb │ │ ├── response_error.rb │ │ ├── unexpected_error.rb │ │ ├── invalid_response_error.rb │ │ ├── invalid_request_error.rb │ │ └── unsuccessful_response_error.rb ├── representers │ ├── generated_entity_representer.rb │ ├── entity_representer.rb │ ├── generated_invoice_representer.rb │ ├── afip_person_representer.rb │ └── invoice_with_details_representer.rb ├── managers │ └── entity_manager.rb ├── pdfs │ └── to_pdf.rb └── uploaders │ └── logo_uploader.rb ├── spec ├── support │ ├── carrierwave │ │ └── .keep │ ├── resources │ │ └── image.jpeg │ ├── services │ │ ├── auth │ │ │ └── encryptor_support.rb │ │ ├── afip │ │ │ ├── entity_data_generator_support.rb │ │ │ └── person_support.rb │ │ └── invoice │ │ │ ├── recipient_loader_support.rb │ │ │ ├── builder_support.rb │ │ │ ├── validator_support.rb │ │ │ ├── query_builder_support.rb │ │ │ ├── finder_support.rb │ │ │ └── generator_support.rb │ ├── controllers │ │ ├── afip_people_controller_support.rb │ │ ├── static_controller_support.rb │ │ └── entities_controller_support.rb │ ├── models │ │ ├── entity_support.rb │ │ └── invoice_support.rb │ └── responses │ │ ├── person_not_found_response.xml │ │ ├── last_bill_number_response.xml │ │ ├── sale_points_error_response.xml │ │ ├── person_with_invalid_address.xml │ │ ├── concept_types_response.xml │ │ ├── other_sale_points_response.xml │ │ ├── invoice_not_found_response.xml │ │ ├── iva_types_response.xml │ │ ├── create_invoice_response.xml │ │ ├── sale_points_response.xml │ │ ├── invoice_response.xml │ │ ├── tax_types_response.xml │ │ ├── create_invoice_error_response.xml │ │ ├── login_response.xml │ │ ├── natural_responsible_person_response.xml │ │ ├── product_invoice_response.xml │ │ ├── wsaa_wsdl.xml │ │ ├── natural_single_taxpayer_person_response.xml │ │ └── legal_person_response.xml ├── factories │ ├── associated_invoices.rb │ ├── afip_requests.rb │ ├── invoice_items.rb │ └── invoices.rb ├── test_helper.rb ├── carrierwave_helper.rb ├── services │ ├── auth │ │ ├── encryptor_spec.rb │ │ └── token_validator_spec.rb │ ├── afip │ │ ├── entity_data_generator_spec.rb │ │ ├── people_service_spec.rb │ │ └── person_spec.rb │ └── invoice │ │ ├── builder_spec.rb │ │ ├── finder_spec.rb │ │ └── recipient_loader_spec.rb ├── models │ ├── associated_invoice_spec.rb │ ├── afip_request_spec.rb │ ├── entity_spec.rb │ └── invoice_spec.rb ├── routing │ ├── afip_people_routing_spec.rb │ ├── entity_routing_spec.rb │ ├── invoice_routing_spec.rb │ └── static_routing_spec.rb ├── mocks │ ├── people_service_mock.rb │ ├── afip_mock.rb │ └── invoices_service_mock.rb ├── shared_examples │ ├── shared_examples_for_controllers.rb │ └── shared_examples_for_afip.rb ├── controllers │ └── v1 │ │ └── afip_people_controller_spec.rb └── spec_helper.rb ├── public ├── factura_24.pdf └── robots.txt ├── flowcharts ├── autenticacion_afip.png └── generar_comprobante.png ├── bin ├── bundle ├── rake ├── rails ├── spring ├── update └── setup ├── config ├── boot.rb ├── spring.rb ├── environment.rb ├── cable.yml ├── initializers │ ├── mime_types.rb │ ├── filter_parameter_logging.rb │ ├── application_controller_renderer.rb │ ├── backtrace_silencers.rb │ ├── carrierwave.rb │ ├── wrap_parameters.rb │ ├── cors.rb │ └── inflections.rb ├── deploy │ ├── staging.rb │ └── production.rb ├── database.yml ├── application.yml.sample ├── routes.rb ├── locales │ └── en.yml ├── application.rb ├── environments │ ├── test.rb │ ├── development.rb │ └── production.rb └── puma.rb ├── config.ru ├── Rakefile ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── documentation-improvement.md │ ├── bug-report.md │ └── feature-request.md └── workflows │ ├── rubocop.yml │ └── rspec.yml ├── docker └── docker-entrypoint.sh ├── Capfile ├── .env.sample ├── postman └── environment_example.postman_environment.json ├── docker-compose.yml ├── .gitignore ├── LICENCE ├── .rubocop.yml ├── Dockerfile ├── Gemfile ├── CHANGELOG.md └── CONTRIBUTING.md /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /db/test.sqlite3: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.6 2 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/capistrano/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/support/carrierwave/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /db/development.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unagisoftware/afip-invoices/HEAD/db/development.sqlite3 -------------------------------------------------------------------------------- /public/factura_24.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unagisoftware/afip-invoices/HEAD/public/factura_24.pdf -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /flowcharts/autenticacion_afip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unagisoftware/afip-invoices/HEAD/flowcharts/autenticacion_afip.png -------------------------------------------------------------------------------- /flowcharts/generar_comprobante.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unagisoftware/afip-invoices/HEAD/flowcharts/generar_comprobante.png -------------------------------------------------------------------------------- /spec/support/resources/image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unagisoftware/afip-invoices/HEAD/spec/support/resources/image.jpeg -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /app/models/associated_invoice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AssociatedInvoice < ApplicationRecord 4 | include Invoiceable 5 | 6 | belongs_to :invoice 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20190114174946_add_logo_to_entities.rb: -------------------------------------------------------------------------------- 1 | class AddLogoToEntities < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :entities, :logo, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20190329172313_add_note_to_invoices.rb: -------------------------------------------------------------------------------- 1 | class AddNoteToInvoices < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :invoices, :note, :text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 4 | 5 | require 'bundler/setup' # Set up gems listed in the Gemfile. 6 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | %w[ 4 | .ruby-version 5 | .rbenv-vars 6 | tmp/restart.txt 7 | tmp/caching-dev.txt 8 | ].each { |path| Spring.watch(path) } 9 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | -------------------------------------------------------------------------------- /db/migrate/20190318183753_add_logo_url_to_invoice.rb: -------------------------------------------------------------------------------- 1 | class AddLogoUrlToInvoice < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :invoices, :logo_url, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210417205208_add_index_to_invoice.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToInvoice < ActiveRecord::Migration[5.1] 2 | def change 3 | add_index :invoices, [:bill_type_id, :receipt] 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180418170134_add_auth_token_to_entity.rb: -------------------------------------------------------------------------------- 1 | class AddAuthTokenToEntity < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :entities, :auth_token, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20190430183512_add_recipient_to_invoices.rb: -------------------------------------------------------------------------------- 1 | class AddRecipientToInvoices < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :invoices, :recipient, :jsonb 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /db/migrate/20180418171010_add_null_constraint_to_entity.rb: -------------------------------------------------------------------------------- 1 | class AddNullConstraintToEntity < ActiveRecord::Migration[5.1] 2 | def change 3 | change_column_null :entities, :auth_token, true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180417215413_add_bill_type_id_to_invoice.rb: -------------------------------------------------------------------------------- 1 | class AddBillTypeIdToInvoice < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :invoices, :bill_type_id, :integer, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20190109203507_update_column_of_invoice_item.rb: -------------------------------------------------------------------------------- 1 | class UpdateColumnOfInvoiceItem < ActiveRecord::Migration[5.1] 2 | def change 3 | change_column_null :invoice_items, :metric_unit, true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: redis://localhost:6379/1 10 | channel_prefix: afip-invoices_production 11 | -------------------------------------------------------------------------------- /db/migrate/20180423192212_rename_invoice_item_name_to_description.rb: -------------------------------------------------------------------------------- 1 | class RenameInvoiceItemNameToDescription < ActiveRecord::Migration[5.1] 2 | def change 3 | rename_column :invoice_items, :name, :description 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new mime types for use in respond_to blocks: 5 | # Mime::Type.register "text/richtext", :rtf 6 | -------------------------------------------------------------------------------- /spec/support/services/auth/encryptor_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Auth 4 | class EncryptorSupport 5 | KEY = "\xCBvm-pF2G\x88c\x1C\xAD;\xE9\xBE\x96=\x02\xA8oiX\xAB\fB\xC5\x8AM\xB0\x1DY+" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210907143040_add_index_to_entity.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToEntity < ActiveRecord::Migration[6.1] 2 | def change 3 | add_index :entities, :business_name, unique: true 4 | add_index :entities, :cuit, unique: true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20190925142300_add_optional_columns_to_invoices.rb: -------------------------------------------------------------------------------- 1 | class AddOptionalColumnsToInvoices < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :invoices, :cbu, :string 4 | add_column :invoices, :alias, :string 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /spec/support/controllers/afip_people_controller_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../services/afip/person_support' 4 | 5 | class AfipPeopleControllerSupport 6 | RESPONSE_FORMAT = Afip::PersonSupport::RESPONSE_FORMAT.dup.freeze 7 | end 8 | -------------------------------------------------------------------------------- /config/deploy/staging.rb: -------------------------------------------------------------------------------- 1 | server ENV['STAGING_SERVER_SSH_NAME'], port: 22, roles: [:web, :app, :db] 2 | 3 | set :rails_env, 'staging' 4 | set :stage, :staging 5 | 6 | if ENV['BRANCH'] 7 | set :branch, ENV['BRANCH'] if ENV['BRANCH'] 8 | else 9 | set :branch, 'main' 10 | end 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure sensitive parameters which will be filtered from the log file. 6 | Rails.application.config.filter_parameters += [:password] 7 | -------------------------------------------------------------------------------- /spec/support/models/entity_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EntitySupport 4 | JSON_FORMAT = { 5 | business_name: String, 6 | csr: String, 7 | cuit: String, 8 | id: Integer, 9 | logo_url: String, 10 | name: String, 11 | }.freeze 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/admin_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminController < ApplicationController 4 | protected 5 | 6 | def authenticate 7 | authenticate_or_request_with_http_token do |token, _options| 8 | token == ENV['AUTH_TOKEN'] 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/services/afip/entity_data_generator_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class EntityDataGeneratorSupport 5 | RESPONSE_FORMAT = { 6 | csr: OpenSSL::X509::Request, 7 | pkey: String, 8 | subject: String, 9 | }.freeze 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/services/static_resource/currencies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class Currencies < Base 5 | private 6 | 7 | def operation 8 | :fe_param_get_tipos_monedas 9 | end 10 | 11 | def resource 12 | :moneda 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../config/application', __dir__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .byebug_history 2 | .env* 3 | .git 4 | .gitignore 5 | .gitlab-ci.yml 6 | .travis.yml 7 | .rubocop.yml 8 | Brewfile 9 | Procfile* 10 | docker-compose-*.yml 11 | Dockerfile 12 | coverage/* 13 | log/* 14 | node_modules/* 15 | public/assets/* 16 | storage/* 17 | tmp/* 18 | 19 | config/application.yml 20 | -------------------------------------------------------------------------------- /app/services/static_resource/document_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class DocumentTypes < Base 5 | private 6 | 7 | def operation 8 | :fe_param_get_tipos_doc 9 | end 10 | 11 | def resource 12 | :doc_tipo 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/services/static_resource/optionals.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class Optionals < Base 5 | private 6 | 7 | def operation 8 | :fe_param_get_tipos_opcional 9 | end 10 | 11 | def resource 12 | :opcional_tipo 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/services/static_resource/tax_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class TaxTypes < Base 5 | private 6 | 7 | def operation 8 | :fe_param_get_tipos_tributos 9 | end 10 | 11 | def resource 12 | :tributo_tipo 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/services/static_resource/concept_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class ConceptTypes < Base 5 | private 6 | 7 | def operation 8 | :fe_param_get_tipos_concepto 9 | end 10 | 11 | def resource 12 | :concepto_tipo 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/factories/associated_invoices.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :associated_invoice do 5 | association :invoice 6 | 7 | bill_type_id { 11 } 8 | emission_date { Date.yesterday } 9 | receipt { "0001-#{Faker::Number.number(digits: 8)}" } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/errors/afip/timeout_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class TimeoutError < BaseError 5 | ERROR_MESSAGE = 'timeout de conexión con AFIP' 6 | 7 | def initialize 8 | logger.error(message: ERROR_MESSAGE) 9 | 10 | super(ERROR_MESSAGE) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/responses/person_not_found_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | soap:Server 5 | No existe persona con ese Id 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/models/afip_request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AfipRequest < ApplicationRecord 4 | validates :bill_number, presence: true 5 | validates :bill_type_id, presence: true 6 | validates :invoice_id_client, presence: true 7 | validates :sale_point_id, presence: true 8 | 9 | belongs_to :entity 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # ActiveSupport::Reloader.to_prepare do 5 | # ApplicationController.renderer.defaults.merge!( 6 | # http_host: 'example.org', 7 | # https: false 8 | # ) 9 | # end 10 | -------------------------------------------------------------------------------- /spec/support/services/afip/person_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Afip::PersonSupport 4 | RESPONSE_FORMAT = { 5 | address: String, 6 | category: String, 7 | city: String, 8 | full_address: String, 9 | name: String, 10 | state: String, 11 | zipcode: String, 12 | }.freeze 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20181127190744_add_token_to_invoices.rb: -------------------------------------------------------------------------------- 1 | class AddTokenToInvoices < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :invoices, :token, :string 4 | Invoice.all.each { |i| i.regenerate_token } 5 | 6 | change_column_null :invoices, :token, false 7 | add_index :invoices, :token, unique: true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20190215205550_add_columns_to_entities.rb: -------------------------------------------------------------------------------- 1 | class AddColumnsToEntities < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :entities, :condition_against_iva, :string 4 | add_column :entities, :activity_start_date, :date 5 | add_column :entities, :comertial_address, :string 6 | add_column :entities, :iibb, :string 7 | end 8 | end -------------------------------------------------------------------------------- /app/representers/generated_entity_representer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GeneratedEntityRepresenter < Representable::Decorator 4 | include Representable::JSON 5 | 6 | property :business_name 7 | property :csr 8 | property :cuit 9 | property :id 10 | property :name 11 | 12 | collection_representer class: Entity 13 | end 14 | -------------------------------------------------------------------------------- /lib/tasks/invoices_load_recipients.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :invoices do 4 | desc "Load missing invoices' recipients" 5 | task load_recipients: :environment do 6 | Invoice.all.where(recipient: nil).includes(:entity).each do |invoice| 7 | LoadRecipientIntoInvoice.new(invoice).call! 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/factories/afip_requests.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :afip_request do 5 | association :entity 6 | 7 | bill_number { "0001-#{Faker::Number.number(digits: 8)}" } 8 | bill_type_id { 11 } 9 | invoice_id_client { Faker::Number.number(digits: 1) } 10 | sale_point_id { 1 } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/v1/afip_people_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module V1 4 | class AfipPeopleController < ApplicationController 5 | def show 6 | render json: Afip::Person.new(person_params, entity).info 7 | end 8 | 9 | private 10 | 11 | def person_params 12 | params.permit(:cuit) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/controllers/static_controller_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class StaticControllerSupport 4 | SALE_POINT_FORMAT = { 5 | enabled: [TrueClass, FalseClass], 6 | id: String, 7 | name: String, 8 | type: String, 9 | }.freeze 10 | 11 | IVA_TYPE_FORMAT = { 12 | id: String, 13 | name: String, 14 | }.freeze 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20180327215733_create_entities.rb: -------------------------------------------------------------------------------- 1 | class CreateEntities < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :entities do |t| 4 | t.text :certificate 5 | t.text :private_key 6 | t.text :csr 7 | t.string :cuit 8 | t.string :name 9 | t.string :business_name 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/errors/afip/base_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class BaseError < RuntimeError 5 | ERROR = 'Error de conexión con AFIP' 6 | 7 | def initialize(error) 8 | super("#{ERROR}: #{error}.") 9 | end 10 | 11 | private 12 | 13 | def logger 14 | @logger ||= Loggers::AfipConnection.new(json: true) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/errors/afip/response_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class ResponseError < BaseError 5 | def initialize(error_message:, message:, response:) 6 | logger.error( 7 | message: error_message, 8 | parameters: message, 9 | response: response, 10 | ) 11 | 12 | super(error_message) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/errors/afip/unexpected_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class UnexpectedError < BaseError 5 | ERROR_MESSAGE = 'error no esperado' 6 | 7 | def initialize(error:) 8 | logger.error( 9 | backtrace: error.backtrace, 10 | message: error.message, 11 | ) 12 | 13 | super(ERROR_MESSAGE) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/tasks/fix_database.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :fix_database do 4 | desc 'Encrypt auth_token on existing entities' 5 | task encrypt_auth_tokens: :environment do 6 | Entity.all.each do |entity| 7 | original_token = entity.read_attribute(:auth_token) 8 | entity.auth_token = original_token 9 | entity.save 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20180417184833_create_invoices.rb: -------------------------------------------------------------------------------- 1 | class CreateInvoices < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :invoices do |t| 4 | t.references :entity, index: true, foreign_key: true 5 | t.date :emission_date, null: false 6 | t.string :authorization_code, null: false 7 | t.string :receipt, null: false 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20180423200117_add_null_constraints_to_entity.rb: -------------------------------------------------------------------------------- 1 | class AddNullConstraintsToEntity < ActiveRecord::Migration[5.1] 2 | def change 3 | change_column_null :entities, :private_key, true 4 | change_column_null :entities, :cuit, true 5 | change_column_null :entities, :name, true 6 | change_column_null :entities, :business_name, true 7 | change_column_null :entities, :csr, true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # This file should contain all the record creation needed to seed the database with its default values. 3 | # The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). 4 | # 5 | # Examples: 6 | # 7 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) 8 | # Character.create(name: 'Luke', movie: movies.first) 9 | -------------------------------------------------------------------------------- /spec/support/services/invoice/recipient_loader_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class RecipientLoaderSupport 5 | RECIPIENT_FORMAT = { 6 | 'address' => String, 7 | 'category' => String, 8 | 'city' => String, 9 | 'full_address' => String, 10 | 'name' => String, 11 | 'state' => String, 12 | 'zipcode' => String, 13 | }.freeze 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | 3 | require_relative '../config/environment' 4 | require 'spec_helper' 5 | require 'rails/test_help' 6 | require 'mocks/afip_mock' 7 | require 'mocks/invoices_service_mock' 8 | require 'mocks/people_service_mock.rb' 9 | 10 | if Rails.env.production? 11 | abort('The Rails environment is running in production mode!') 12 | end 13 | 14 | ActiveRecord::Migration.maintain_test_schema! 15 | -------------------------------------------------------------------------------- /app/services/auth/token_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Auth 4 | class TokenValidator 5 | TTL = 10.minutes.freeze 6 | 7 | def initialize(token) 8 | @token = token 9 | end 10 | 11 | def call 12 | Rails 13 | .cache 14 | .fetch(@token, expires_in: TTL) do 15 | Entity.all.find { |entity| entity.auth_token == @token } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20200814175226_create_associated_invoices.rb: -------------------------------------------------------------------------------- 1 | class CreateAssociatedInvoices < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :associated_invoices do |t| 4 | t.string :receipt, null: false 5 | t.date :emission_date, null: false 6 | t.integer :bill_type_id, null: false 7 | t.references :invoice, index: true, null: false, foreign_key: true 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20190110114135_add_iva_aliquot_to_invoice_items.rb: -------------------------------------------------------------------------------- 1 | class AddIvaAliquotToInvoiceItems < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :invoice_items, 4 | :iva_aliquot, 5 | :decimal, 6 | precision: 5, 7 | scale: 2, 8 | null: true 9 | 10 | InvoiceItem.where(iva_aliquot: nil).update_all(iva_aliquot: 0.0) 11 | 12 | change_column_null :invoice_items, :iva_aliquot, false 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation improvement 3 | about: Suggest changes or improvements to documentation 4 | title: '' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe which part of the documentation should be improved** 11 | A clear and concise description of what is needed to improve the documentation. 12 | 13 | **Additional context** 14 | Add any other context about the improvement here. 15 | -------------------------------------------------------------------------------- /spec/support/models/invoice_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InvoiceSupport 4 | QR_FORMAT = { 5 | codAut: Integer, 6 | ctz: Float, 7 | cuit: Integer, 8 | fecha: String, 9 | importe: Float, 10 | moneda: String, 11 | nroCmp: Integer, 12 | nroDocRec: Integer, 13 | ptoVta: Integer, 14 | tipoCmp: Integer, 15 | tipoCodAut: String, 16 | tipoDocRec: Integer, 17 | ver: Integer, 18 | }.freeze 19 | end 20 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 5 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 6 | 7 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 8 | # Rails.backtrace_cleaner.remove_silencers! 9 | -------------------------------------------------------------------------------- /spec/factories/invoice_items.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :invoice_item do 5 | association :invoice 6 | 7 | bonus_percentage { 0 } 8 | code { Faker::Number.number(digits: 10) } 9 | description { Faker::Lorem.sentence } 10 | iva_aliquot_id { 5 } 11 | metric_unit { 'unidades' } 12 | quantity { Faker::Number.number(digits: 1) } 13 | unit_price { Faker::Number.decimal(l_digits: 3, r_digits: 2) } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/errors/afip/invalid_response_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class InvalidResponseError < BaseError 5 | def initialize(error:, message:, response:) 6 | error_message = 'respuesta de servidor inválida' 7 | 8 | logger.error( 9 | backtrace: error.backtrace, 10 | message: error_message, 11 | parameters: message, 12 | response: response, 13 | ) 14 | 15 | super(error_message) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/representers/entity_representer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EntityRepresenter < Representable::Decorator 4 | include Representable::JSON 5 | 6 | property :auth_token 7 | property :business_name 8 | property :completed, exec_context: :decorator 9 | property :created_at 10 | property :cuit 11 | property :id 12 | property :name 13 | 14 | collection_representer class: Entity 15 | 16 | def completed 17 | represented.certificate.present? 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/responses/last_bill_number_response.xml: -------------------------------------------------------------------------------- 1 | 1110 2 | -------------------------------------------------------------------------------- /spec/support/services/invoice/builder_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative './generator_support' 4 | require_relative './validator_support' 5 | 6 | class Invoice 7 | class BuilderSupport 8 | PARAMS = Invoice::GeneratorSupport::PARAMS 9 | .merge(bill_number: Faker::Number.number(digits: 2)) 10 | .deep_dup 11 | .freeze 12 | 13 | ASSOCIATED_INVOICE = Invoice::ValidatorSupport::ASSOCIATED_INVOICE 14 | .dup 15 | .freeze 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/concerns/invoiceable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/concern' 4 | 5 | module Invoiceable 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | validates :bill_type_id, presence: true 10 | validates :emission_date, presence: true 11 | validates :receipt, presence: true 12 | 13 | def sale_point_id 14 | receipt.split('-').first 15 | end 16 | 17 | def bill_number 18 | receipt.split('-').last 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /db/migrate/20210417192854_create_afip_requests.rb: -------------------------------------------------------------------------------- 1 | class CreateAfipRequests < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :afip_requests do |t| 4 | t.integer :bill_type_id, null: false 5 | t.bigint :invoice_id_client, null: false 6 | t.integer :sale_point_id, null: false 7 | t.string :bill_number, null: false 8 | t.references :entity, foreign_key: true, null: false 9 | t.timestamps 10 | end 11 | 12 | add_index :afip_requests, :invoice_id_client 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/services/afip/people_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class PeopleService < Middleware 5 | ENDPOINT = ENV['PADRON_A5_URL'] 6 | SERVICE = 'ws_sr_padron_a5' 7 | 8 | private 9 | 10 | def auth_params 11 | { 12 | 'token' => token, 13 | 'sign' => sign, 14 | 'cuitRepresentada' => entity_cuit, 15 | } 16 | end 17 | 18 | def endpoint 19 | ENDPOINT 20 | end 21 | 22 | def service 23 | SERVICE 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Step 1 16 | 2. Step 2 17 | 3. Step 3 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # https://stackoverflow.com/a/38732187/1935918 3 | set -e 4 | 5 | if [ -f /app/tmp/pids/server.pid ]; then 6 | rm /app/tmp/pids/server.pid 7 | fi 8 | 9 | # if [ -f /app/tmp/unicorn.pid ]; then 10 | # rm /app/tmp/unicorn.pid 11 | # fi 12 | 13 | if [[ $MIGRATE = "true" ]]; then 14 | bundle exec rake db:migrate 15 | fi 16 | 17 | # if [[ -z $REQUEST_TIMEOUT ]]; then 18 | # REQUEST_TIMEOUT=60 19 | # fi 20 | 21 | # sed -i "s/REQUEST_TIMEOUT/${REQUEST_TIMEOUT}/" /app/docker/unicorn.rb 22 | 23 | exec "$@" -------------------------------------------------------------------------------- /app/services/afip/invoices_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class InvoicesService < Middleware 5 | ENDPOINT = ENV['WSFE_URL'] 6 | SERVICE = 'wsfe' 7 | 8 | private 9 | 10 | def auth_params 11 | { 12 | 'Auth' => { 13 | 'Token' => token, 14 | 'Sign' => sign, 15 | 'Cuit' => entity_cuit, 16 | }, 17 | } 18 | end 19 | 20 | def endpoint 21 | ENDPOINT 22 | end 23 | 24 | def service 25 | SERVICE 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /Capfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'capistrano/setup' 4 | require 'capistrano/deploy' 5 | require 'capistrano/bundler' 6 | require 'capistrano/rails/migrations' 7 | require 'capistrano/rbenv' 8 | require 'capistrano/puma' 9 | require 'figaro' 10 | 11 | install_plugin Capistrano::Puma 12 | install_plugin Capistrano::Puma::Systemd 13 | 14 | Figaro.application = Figaro::Application.new( 15 | path: File.expand_path('config/application.yml', __dir__), 16 | ) 17 | 18 | Figaro.load 19 | 20 | Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r } 21 | -------------------------------------------------------------------------------- /app/errors/afip/invalid_request_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class InvalidRequestError < BaseError 5 | def initialize(error:, http_status_code:, message:, response:) 6 | error_message = "solicitud inválida con error '#{error}'" 7 | 8 | logger.error( 9 | backtrace: error.backtrace, 10 | http_status_code: http_status_code, 11 | message: error_message, 12 | parameters: message, 13 | response: response, 14 | ) 15 | 16 | super(error_message) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/initializers/carrierwave.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if Rails.env.test? 4 | CarrierWave.configure do |config| 5 | config.storage = :file 6 | config.enable_processing = false 7 | end 8 | 9 | LogoUploader 10 | 11 | CarrierWave::Uploader::Base.descendants.each do |klass| 12 | next if klass.anonymous? 13 | 14 | klass.class_eval do 15 | def cache_dir 16 | Rails.root.join('tmp/uploads') 17 | end 18 | 19 | def store_dir 20 | Rails.root.join('tmp/uploads') 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | spring = lockfile.specs.detect { |spec| spec.name == "spring" } 12 | if spring 13 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 14 | gem 'spring', spring.version 15 | require 'spring/binstub' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/carrierwave_helper.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | carrierwave_root = Rails.root.join('spec', 'support', 'carrierwave') 4 | 5 | # Carrierwave configuration is set here instead of in initializer 6 | CarrierWave.configure do |config| 7 | config.root = carrierwave_root 8 | config.enable_processing = false 9 | config.storage = :file 10 | config.cache_dir = Rails.root.join('spec', 'support', 'carrierwave', 'carrierwave_cache') 11 | end 12 | 13 | at_exit do 14 | Dir.glob(carrierwave_root.join('*')).each do |dir| 15 | FileUtils.remove_entry(dir) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20180417191443_create_invoice_items.rb: -------------------------------------------------------------------------------- 1 | class CreateInvoiceItems < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :invoice_items do |t| 4 | t.string :code 5 | t.string :name, null: false 6 | t.float :quantity, null: false, default: 1.0 7 | t.float :unit_price, null: false, default: 0.0 8 | t.float :bonus_percentage, null: false, default: 0.0 9 | t.string :metric_unit, null: false, default: 'unidades' 10 | t.references :invoice, index: true, foreign_key: true 11 | 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/controllers/entities_controller_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EntitiesControllerSupport 4 | ENTITY_SHORT_FORMAT = { 5 | business_name: String, 6 | csr: [NilClass, String], 7 | cuit: String, 8 | id: Integer, 9 | logo_url: [NilClass, String], 10 | name: String, 11 | }.freeze 12 | 13 | ENTITY_LONG_FORMAT = { 14 | auth_token: String, 15 | business_name: String, 16 | completed: [TrueClass, FalseClass], 17 | created_at: String, 18 | cuit: String, 19 | id: Integer, 20 | name: String, 21 | }.freeze 22 | end 23 | -------------------------------------------------------------------------------- /spec/services/auth/encryptor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/services/auth/encryptor_support' 5 | 6 | describe Auth::Encryptor do 7 | before do 8 | stub_const('Auth::TokenEncryptor::KEY', Auth::EncryptorSupport::KEY) 9 | end 10 | 11 | it 'encrypt token' do 12 | expect(described_class.encrypt('my_token')).to be_a_kind_of(String) 13 | end 14 | 15 | it 'descrypt token' do 16 | encripted_value = described_class.encrypt('my_token') 17 | expect(described_class.decrypt(encripted_value)).to eq('my_token') 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Rubocop 2 | on: 3 | pull_request: 4 | pull_request_review: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | rubocop: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: 2.7.6 20 | bundler-cache: true 21 | - name: Install dependencies and run rubocop 22 | run: | 23 | gem install bundler 24 | bundle install --jobs 4 --retry 3 25 | bundle exec rubocop 26 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [:json] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /app/errors/afip/unsuccessful_response_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class UnsuccessfulResponseError < BaseError 5 | def initialize(error:, http_status_code:, message:, response:) 6 | error_message = "respuesta no exitosa (HTTP #{http_status_code}) con error '#{error.message}'" 7 | 8 | logger.error( 9 | backtrace: error.backtrace, 10 | http_status_code: http_status_code, 11 | message: error_message, 12 | parameters: message, 13 | response: response, 14 | ) 15 | 16 | super(error_message) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/initializers/cors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Avoid CORS issues when API is called from the frontend app. 5 | # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. 6 | 7 | # Read more: https://github.com/cyu/rack-cors 8 | 9 | # Rails.application.config.middleware.insert_before 0, Rack::Cors do 10 | # allow do 11 | # origins 'example.com' 12 | # 13 | # resource '*', 14 | # headers: :any, 15 | # methods: [:get, :post, :put, :patch, :delete, :options, :head] 16 | # end 17 | # end 18 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | DATABASE_USER=YOUR_DATABASE_USER 2 | DATABASE_PASS=YOUR_DATABASE_PASSWORD 3 | DATABASE_HOST=YOUR_DATABASE_HOST 4 | DATABASE_NAME=afip_development 5 | DATABASE_PORT=5432 6 | PORT=3001 7 | RAILS_ENV=development 8 | MIGRATE=true 9 | RAILS_LOG_TO_STDOUT=true 10 | REDIS_URL=redis://redis:6379/0 11 | 12 | LOGIN_URL='https://wsaahomo.afip.gov.ar/ws/services/LoginCms?wsdl' 13 | WSFE_URL='https://wswhomo.afip.gov.ar/wsfev1/service.asmx' 14 | PADRON_A5_URL='https://awshomo.afip.gov.ar/sr-padron/webservices/personaServiceA5' 15 | 16 | AUTH_TOKEN=CREATE_AUTH_TOKEN 17 | SECRET_KEY_BASE=CREATE_SECRET_KEY_BASE 18 | 19 | HOST=PRODUCTION_HOST 20 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 5 | username: <%= ENV['DATABASE_USER'] %> 6 | password: "<%= ENV['DATABASE_PASS'] %>" 7 | host: <%= ENV['DATABASE_HOST'] %> 8 | port: <%= ENV.fetch("DATABASE_PORT") { 5432 } %> 9 | 10 | development: 11 | <<: *default 12 | database: <%= ENV['DATABASE_NAME'] %> 13 | 14 | test: 15 | <<: *default 16 | database: afip_test 17 | 18 | staging: 19 | <<: *default 20 | database: <%= ENV['DATABASE_NAME'] %> 21 | 22 | production: 23 | <<: *default 24 | database: <%= ENV['DATABASE_NAME'] %> 25 | -------------------------------------------------------------------------------- /spec/models/associated_invoice_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | describe AssociatedInvoice, type: :model do 6 | describe 'Basics' do 7 | it 'has a valid factory' do 8 | expect(build(:associated_invoice)).to be_valid 9 | end 10 | end 11 | 12 | describe 'Associations' do 13 | it { is_expected.to belong_to(:invoice) } 14 | end 15 | 16 | describe 'Validations' do 17 | it { is_expected.to validate_presence_of(:bill_type_id) } 18 | it { is_expected.to validate_presence_of(:emission_date) } 19 | it { is_expected.to validate_presence_of(:receipt) } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/services/afip/entity_data_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/services/afip/entity_data_generator_support' 5 | 6 | describe Afip::EntityDataGenerator do 7 | let!(:entity) { create(:entity) } 8 | 9 | before do 10 | RSpec::Mocks.space.proxy_for(described_class).reset 11 | end 12 | 13 | subject { described_class.new(entity) } 14 | 15 | describe '#call' do 16 | it 'returns valid data for generating entities' do 17 | expect(subject.call) 18 | .to match_valid_format(Afip::EntityDataGeneratorSupport::RESPONSE_FORMAT) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/factories/invoices.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :invoice do 5 | association :entity 6 | 7 | authorization_code { Faker::Number.number(digits: 10) } 8 | bill_type_id { 11 } 9 | emission_date { Date.yesterday } 10 | receipt { "0001-#{Faker::Number.number(digits: 8)}" } 11 | token { SecureRandom.hex(10) } 12 | 13 | factory :note do 14 | bill_type_id { 2 } 15 | end 16 | 17 | factory :electronic_credit_invoice do 18 | bill_type_id { 201 } 19 | end 20 | 21 | factory :electronic_note do 22 | bill_type_id { 202 } 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/services/loggers/afip_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Loggers 4 | class AfipConnection 5 | def initialize(name = 'afip', json: false) 6 | @logfile = Logger.new("log/#{name}_error.log") 7 | @json = json 8 | end 9 | 10 | def error(message:, backtrace: nil, http_status_code: nil, parameters: nil, response: nil) 11 | msg = { 12 | backtrace: backtrace, 13 | http_status_code: http_status_code, 14 | message: message, 15 | parameters: parameters, 16 | response: response, 17 | } 18 | 19 | msg = msg.to_json if @json 20 | 21 | @logfile.error(msg) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/services/static_resource/sale_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class SalePoints < Base 5 | private 6 | 7 | def operation 8 | :fe_param_get_ptos_venta 9 | end 10 | 11 | def resource 12 | :pto_venta 13 | end 14 | 15 | def format_item(item) 16 | return unless valid_item?(item) 17 | 18 | { 19 | id: item[:nro], 20 | name: item[:nro].rjust(4, '0'), 21 | type: item[:emision_tipo], 22 | enabled: item[:bloqueado].to_s.casecmp('N').zero?, 23 | } 24 | end 25 | 26 | def valid_item?(item) 27 | item[:fch_baja] == 'NULL' 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/services/static_resource/iva_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class IvaTypes < Base 5 | EXEMPT_ID = 98 6 | UNTAXED_ID = 99 7 | 8 | private 9 | 10 | def operation 11 | :fe_param_get_tipos_iva 12 | end 13 | 14 | def resource 15 | :iva_tipo 16 | end 17 | 18 | def format_response(response) 19 | result = super(response) 20 | 21 | return result if result.is_a?(Hash) && result.key?(:error) 22 | 23 | result << { id: IvaTypes::EXEMPT_ID.to_s, name: 'Exento' } 24 | result << { id: IvaTypes::UNTAXED_ID.to_s, name: 'No gravado' } 25 | 26 | result 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /postman/environment_example.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dcc6e9bd-b673-448e-9dfd-20af0f0a6cbd", 3 | "name": "Open Source Example", 4 | "values": [ 5 | { 6 | "key": "ADMIN_TOKEN", 7 | "value": "your_admin_token", 8 | "enabled": true 9 | }, 10 | { 11 | "key": "TOKEN", 12 | "value": "your_enity_token", 13 | "enabled": true 14 | }, 15 | { 16 | "key": "HOST", 17 | "value": "http://localhost:3000", 18 | "enabled": true 19 | } 20 | ], 21 | "_postman_variable_scope": "environment", 22 | "_postman_exported_at": "2021-08-26T17:07:18.988Z", 23 | "_postman_exported_using": "Postman/8.12.0-210823-1230" 24 | } 25 | -------------------------------------------------------------------------------- /spec/services/afip/people_service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'shared_examples/shared_examples_for_afip' 5 | 6 | describe Afip::PeopleService do 7 | let(:entity) { create(:entity) } 8 | 9 | subject { described_class.new(entity) } 10 | 11 | describe '#call' do 12 | let!(:afip_mocks) do 13 | [ 14 | PeopleServiceMock.mock(:login_wsdl), 15 | PeopleServiceMock.mock(:login), 16 | PeopleServiceMock.mock(:ws_wsdl), 17 | PeopleServiceMock.mock(:natural_responsible_person), 18 | ] 19 | end 20 | 21 | it_behaves_like 'AFIP WS operation execution', 22 | :get_persona 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/responses/sale_points_error_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 602 9 | Sin Resultados: - Metodo FEParamGetPtosVenta 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/models/afip_request_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | describe AfipRequest, type: :model do 6 | describe 'Basics' do 7 | it 'has a valid factory' do 8 | expect(build(:afip_request)).to be_valid 9 | end 10 | end 11 | 12 | describe 'Associations' do 13 | it { is_expected.to belong_to(:entity) } 14 | end 15 | 16 | describe 'Validations' do 17 | it { is_expected.to validate_presence_of(:bill_number) } 18 | it { is_expected.to validate_presence_of(:bill_type_id) } 19 | it { is_expected.to validate_presence_of(:invoice_id_client) } 20 | it { is_expected.to validate_presence_of(:sale_point_id) } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/routing/afip_people_routing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe 'routes for Afip People' do 4 | let(:cuit) { '111111111111111' } 5 | 6 | describe 'without version' do 7 | let(:prefix_url) { '/afip_people' } 8 | 9 | it 'routes GET /afip_people to show' do 10 | expect(get("#{prefix_url}/#{cuit}")).to route_to(controller: 'v1/afip_people', action: 'show', cuit: cuit) 11 | end 12 | end 13 | 14 | describe 'v1' do 15 | let(:prefix_url) { '/v1/afip_people' } 16 | 17 | it 'routes GET /v1/afip_people to show' do 18 | expect(get("#{prefix_url}/#{cuit}")).to route_to(controller: 'v1/afip_people', action: 'show', cuit: cuit) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/services/auth/encryptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Auth 4 | class Encryptor 5 | KEY = ActiveSupport::KeyGenerator.new( 6 | ENV.fetch('SECRET_KEY_BASE'), 7 | ).generate_key( 8 | ENV.fetch('ENCRYPTION_SERVICE_SALT'), 9 | ActiveSupport::MessageEncryptor.key_len, 10 | ).freeze 11 | 12 | delegate :encrypt_and_sign, :decrypt_and_verify, to: :encryptor 13 | 14 | def self.encrypt(data) 15 | new.encrypt_and_sign(data) 16 | end 17 | 18 | def self.decrypt(data) 19 | new.decrypt_and_verify(data) 20 | end 21 | 22 | private 23 | 24 | def encryptor 25 | ActiveSupport::MessageEncryptor.new(KEY) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | services: 3 | db: 4 | image: postgres:12 5 | environment: 6 | POSTGRES_USER: admin 7 | POSTGRES_PASSWORD: root 8 | PGDATA: /data/postgres 9 | POSTGRES_DB: afip_development 10 | volumes: 11 | - postgres:/data/postgres 12 | restart: unless-stopped 13 | redis: 14 | image: 'redis:5-alpine' 15 | command: redis-server 16 | volumes: 17 | - 'redis:/data' 18 | app: 19 | build: . 20 | volumes: 21 | - ./app:/app/app 22 | ports: 23 | - "3001:3001" 24 | depends_on: 25 | - db 26 | env_file: 27 | - .env 28 | volumes: 29 | redis: 30 | postgres: 31 | external: 32 | name: postgres-factura -------------------------------------------------------------------------------- /app/models/concerns/encryptable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Encryptable 4 | extend ActiveSupport::Concern 5 | 6 | class_methods do 7 | def attr_encrypted(*attributes) 8 | attributes.each do |attribute| 9 | define_method("#{attribute}=".to_sym) do |value| 10 | return if value.nil? 11 | 12 | public_send( 13 | :write_attribute, 14 | attribute.to_sym, 15 | Auth::Encryptor.encrypt(value), 16 | ) 17 | end 18 | 19 | define_method(attribute) do 20 | value = public_send(:read_attribute, attribute.to_sym) 21 | Auth::Encryptor.decrypt(value) if value.present? 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/responses/person_with_invalid_address.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | VICTORIA CAROLINA 7 | Estado erroneo del domicilio 8 | 20201797064 9 | DARIO 10 | 11 | 12 | 2018-10-03T11:11:27.129-03:00 13 | awshomo.afip.gov.ar 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new inflection rules using the following format. Inflections 5 | # are locale specific, and you may define rules for as many different 6 | # locales as you wish. All of these examples are active by default: 7 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 8 | # inflect.plural /^(ox)$/i, '\1en' 9 | # inflect.singular /^(ox)en/i, '\1' 10 | # inflect.irregular 'person', 'people' 11 | # inflect.uncountable %w( fish sheep ) 12 | # end 13 | 14 | # These inflection rules are supported but not enabled by default: 15 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 16 | # inflect.acronym 'RESTful' 17 | # end 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Vim temp files 8 | *.swp 9 | 10 | # MacOS temp files 11 | .DS_Store 12 | 13 | # Ignore bundler config. 14 | /.bundle 15 | 16 | # Ignore all logfiles and tempfiles. 17 | /log/* 18 | /tmp/* 19 | !/log/.keep 20 | !/tmp/.keep 21 | 22 | .byebug_history 23 | 24 | # Ignore encrypted secrets key file. 25 | config/secrets.yml.key 26 | config/secrets.yml 27 | config/application.yml 28 | .env 29 | 30 | /public/uploads/* 31 | 32 | test/support/carrierwave/* 33 | 34 | coverage 35 | -------------------------------------------------------------------------------- /app/services/invoice/queue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | module Queue 5 | def self.exists?(invoice_id) 6 | AfipRequest.find_by(invoice_id_client: invoice_id).present? 7 | end 8 | 9 | def self.push(bill_type_id, invoice_id, sale_point_id, bill_number, entity) 10 | AfipRequest.create(bill_type_id: bill_type_id, 11 | invoice_id_client: invoice_id, 12 | sale_point_id: sale_point_id, 13 | bill_number: bill_number, 14 | entity: entity) 15 | end 16 | 17 | def self.pop(invoice_id) 18 | AfipRequest.find_by(invoice_id_client: invoice_id) 19 | end 20 | 21 | def self.remove(invoice_id) 22 | AfipRequest.find_by(invoice_id_client: invoice_id).delete if exists?(invoice_id) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/responses/concept_types_response.xml: -------------------------------------------------------------------------------- 1 | 1Producto20100917NULL2Servicios20100917NULL3Productos y Servicios20100917NULL 2 | -------------------------------------------------------------------------------- /spec/support/responses/other_sale_points_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 100 9 | CAE 10 | N 11 | NULL 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/services/afip/entity_data_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class EntityDataGenerator 5 | def initialize(entity) 6 | @entity = entity 7 | end 8 | 9 | def call 10 | key = OpenSSL::PKey::RSA.new(8192) 11 | csr = build_csr(key) 12 | 13 | { 14 | subject: csr_subject, 15 | pkey: key.to_pem, 16 | csr: csr, 17 | } 18 | end 19 | 20 | private 21 | 22 | def build_csr(key) 23 | csr = OpenSSL::X509::Request.new 24 | 25 | csr.version = 0 26 | csr.subject = OpenSSL::X509::Name.parse(csr_subject) 27 | csr.public_key = key.public_key 28 | 29 | csr.sign(key, OpenSSL::Digest.new('SHA1')) 30 | 31 | csr 32 | end 33 | 34 | def csr_subject 35 | "/C=AR/O=#{@entity.name}/CN=Unagi/serialNumber=CUIT #{@entity.cuit}" 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /spec/services/auth/token_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/services/auth/encryptor_support' 5 | 6 | describe Auth::TokenValidator do 7 | before do 8 | stub_const('Auth::TokenEncryptor::KEY', Auth::EncryptorSupport::KEY) 9 | end 10 | 11 | describe '#call' do 12 | let!(:entity) { create(:entity) } 13 | 14 | context 'when token is valid' do 15 | subject { described_class.new(entity.auth_token) } 16 | 17 | it 'returns the associated entity' do 18 | expect(subject.call).to eq(entity) 19 | expect(Rails.cache.read(entity.auth_token)).to eq(entity) 20 | end 21 | end 22 | 23 | context 'when token is invalid' do 24 | subject { described_class.new('invalid_token') } 25 | 26 | it 'returns nil' do 27 | expect(subject.call).to be_nil 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/services/loggers/invoice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Loggers 4 | class Invoice 5 | def initialize(name = 'invoice', json: false) 6 | @logfile_debug = Logger.new("log/#{name}_debug.log") 7 | @logfile_error = Logger.new("log/#{name}_error.log") 8 | @json = json 9 | end 10 | 11 | def debug(action:, message: nil, data: nil) 12 | msg = { 13 | action: action, 14 | message: message, 15 | data: data, 16 | } 17 | 18 | msg = msg.to_json if @json 19 | 20 | @logfile_debug.info(msg) 21 | end 22 | 23 | def error(message:, parameters: nil, response: nil, code: nil) 24 | msg = { 25 | code: code, 26 | message: message, 27 | parameters: parameters, 28 | response: response, 29 | } 30 | 31 | msg = msg.to_json if @json 32 | 33 | @logfile_error.error(msg) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/tasks/credentials_generator.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :credentials_generator do 4 | desc 'Generate AUTH_TOKEN to paste in your env file' 5 | task auth_token: :environment do 6 | token = SecureRandom.hex(12) 7 | 8 | print_instructions('AUTH_TOKEN', token) 9 | end 10 | 11 | desc 'Generate ENCRYPTION_SERVICE_SALT to paste in your env file' 12 | task salt_encryptor: :environment do 13 | salt = SecureRandom.random_bytes(ActiveSupport::MessageEncryptor.key_len) 14 | 15 | print_instructions('ENCRYPTION_SERVICE_SALT', salt) 16 | end 17 | 18 | private 19 | 20 | def print_instructions(env_var_name, env_var_value) 21 | puts ''"Replace or update #{env_var_name} env var 22 | 23 | In your application.yml 24 | 25 | #{env_var_name}: #{env_var_value.inspect} 26 | 27 | In your .env (with Docker) 28 | 29 | #{env_var_name}=#{env_var_value.inspect}"'' 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/support/responses/invoice_not_found_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Homologacion - efa 6 | 2019-04-30T22:05:59.6839025-03:00 7 | 2.12.24.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 602 16 | No existen datos en nuestros registros para los parametros ingresados. 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /spec/support/responses/iva_types_response.xml: -------------------------------------------------------------------------------- 1 | 30%20090220NULL410.5%20090220NULL521%20090220NULL627%20090220NULL85%20141020NULL92.5%20141020NULL 2 | -------------------------------------------------------------------------------- /config/application.yml.sample: -------------------------------------------------------------------------------- 1 | DATABASE_USER: YOUR_DATABASE_USER 2 | DATABASE_PASS: YOUR_DATABASE_PASSWORD 3 | DATABASE_HOST: YOUR_DATABASE_HOST 4 | DATABASE_NAME: afip_development 5 | DATABASE_PORT: '5432' 6 | PORT: '3001' 7 | RAILS_ENV: 'development' 8 | 9 | LOGIN_URL: 'https://wsaahomo.afip.gov.ar/ws/services/LoginCms?wsdl' 10 | WSFE_URL : 'https://wswhomo.afip.gov.ar/wsfev1/service.asmx' 11 | PADRON_A5_URL: 'https://awshomo.afip.gov.ar/sr-padron/webservices/personaServiceA5' 12 | 13 | AUTH_TOKEN: CREATE_AUTH_TOKEN 14 | SECRET_KEY_BASE: CREATE_SECRET_KEY_BASE 15 | 16 | HOST: PRODUCTION_HOST 17 | 18 | # Capistrano 19 | APPLICATION_NAME: YOUR_APPLICATION_NAME 20 | PRODUCTION_SERVER_SSH_NAME: YOUR_PRODUCTION_SERVER_SSH_NAME 21 | REPO_URL: GIT_REPOSITORY_URL 22 | SERVER_APPLICATION_DIRECTORY: APPLICATION_LOCATION_ON_SERVER 23 | SERVER_PG_SYSTEM_DB: 'postgres' 24 | SERVER_PG_SYSTEM_USER: 'postgres' 25 | SERVER_RBENV_BIN_PATH: '$HOME/.rbenv/bin/rbenv' 26 | SERVER_USER: YOUR_SERVER_USER 27 | STAGING_SERVER_SSH_NAME: YOUR_STAGING_SERVER_SSH_NAME 28 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | concern :api do 5 | resources :entities, only: %i[index create show update] 6 | resources :afip_people, only: [:show], param: :cuit 7 | 8 | resources :invoices, only: %i[create show index] do 9 | get :details, on: :collection 10 | get :export, on: :member 11 | post :export_preview, on: :collection 12 | end 13 | 14 | get '/bill_types', to: 'static#bill_types' 15 | get '/concept_types', to: 'static#concept_types' 16 | get '/currencies', to: 'static#currencies' 17 | get '/document_types', to: 'static#document_types' 18 | get '/iva_types', to: 'static#iva_types' 19 | get '/sale_points', to: 'static#sale_points' 20 | get '/tax_types', to: 'static#tax_types' 21 | get '/optionals', to: 'static#optionals' 22 | get '/dummy', to: 'static#is_working' 23 | end 24 | 25 | scope module: 'v1' do 26 | concerns :api 27 | end 28 | 29 | namespace :v1 do 30 | concerns :api 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/managers/entity_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EntityManager 4 | attr_reader :entity, :params 5 | 6 | def initialize(params:, entity: Entity.new) 7 | @entity = entity 8 | @params = sanitize_params(params) 9 | end 10 | 11 | def create_or_update 12 | @entity.assign_attributes(params) 13 | @entity.save! 14 | end 15 | 16 | private 17 | 18 | def sanitize_params(params) 19 | if params.key?(:logo) 20 | file_data = params.delete(:logo) 21 | 22 | params[:logo] = { 23 | tempfile: temp_file(file_data), 24 | filename: file_data[:filename], 25 | content_type: file_data[:content_type], 26 | } 27 | end 28 | 29 | params 30 | end 31 | 32 | def temp_file(file_data) 33 | temp_file = Tempfile.new( 34 | [file_data[:filename], ::File.extname(file_data[:filename])], 35 | encoding: Encoding::BINARY, 36 | ) 37 | temp_file.binmode 38 | temp_file.write(Base64.decode64(file_data[:data])) 39 | temp_file.rewind 40 | temp_file 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/pdfs/to_pdf.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ToPdf < Prawn::Document 4 | include ActionView::Helpers::NumberHelper 5 | 6 | Prawn::Font::AFM.hide_m17n_warning = true 7 | 8 | def initialize(view, page_layout = :portrait) 9 | super(page_layout: page_layout) 10 | @view = view 11 | font 'Helvetica', size: 10 12 | end 13 | 14 | def paragraph(text, *args) 15 | options = args.extract_options! 16 | options[:align] ||= :left 17 | options[:style] ||= :normal 18 | text text, style: options[:style], align: options[:align] 19 | end 20 | 21 | def field(label, content, *args) 22 | options = args.extract_options! 23 | options[:size] ||= 10 24 | 25 | text "#{label}: #{content.presence || '--'}", inline_format: true, 26 | size: options[:size] 27 | move_down 5 28 | end 29 | 30 | # rubocop:disable Rails/FilePath 31 | def uploaded_file_path(url) 32 | return url if Rails.env.test? 33 | 34 | File.join(Rails.root, 'public', url) 35 | end 36 | # rubocop:enable Rails/FilePath 37 | end 38 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | 22 | # puts "\n== Copying sample files ==" 23 | # unless File.exist?('config/database.yml') 24 | # cp 'config/database.yml.sample', 'config/database.yml' 25 | # end 26 | 27 | puts "\n== Preparing database ==" 28 | system! 'bin/rails db:setup' 29 | 30 | puts "\n== Removing old logs and tempfiles ==" 31 | system! 'bin/rails log:clear tmp:clear' 32 | 33 | puts "\n== Restarting application server ==" 34 | system! 'bin/rails restart' 35 | end 36 | -------------------------------------------------------------------------------- /spec/support/responses/create_invoice_response.xml: -------------------------------------------------------------------------------- 1 | 2037679312611201804231729011AN38020308769608111120180423A10063Factura individual, DocTipo: 80, DocNro 20308769608 no se encuentra inscripto en condicion ACTIVA en el impuesto (IVA).6817664646645420180503 2 | -------------------------------------------------------------------------------- /app/services/invoice/recipient_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class RecipientLoader 5 | attr_reader :invoice 6 | 7 | delegate :entity, to: :invoice 8 | 9 | def initialize(invoice) 10 | @invoice = invoice 11 | end 12 | 13 | def call(recipient_cuit = nil) 14 | unless recipient_cuit 15 | invoice_data = Invoice::Finder.new(entity: entity, invoice: invoice).run 16 | return false if invoice_data.nil? 17 | 18 | recipient_cuit = invoice_data.recipient_number 19 | end 20 | 21 | person_data = nil 22 | 23 | begin 24 | person_data = Afip::Person.new({ cuit: recipient_cuit }, entity).info 25 | rescue StandardError => e 26 | Rails.logger.error 'No fue posible obtener la información de la persona.' 27 | Rails.logger.error "Mensaje de error: #{e.message}" 28 | 29 | return false 30 | end 31 | 32 | invoice.recipient = person_data 33 | end 34 | 35 | def call!(recipient_cuit = nil) 36 | call(recipient_cuit) 37 | invoice.save! 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/services/afip/person.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class Person 5 | attr_reader :afip, :params 6 | 7 | def initialize(params, entity) 8 | @afip = Afip::PeopleService.new(entity) 9 | @params = params 10 | end 11 | 12 | def info 13 | response = afip.call(:get_persona, message) 14 | response = response[:get_persona_response][:persona_return] 15 | 16 | raise_response_error(response) if response[:error_constancia].present? 17 | 18 | represent_person(response) 19 | end 20 | 21 | private 22 | 23 | def message 24 | { 'idPersona' => params[:cuit] } 25 | end 26 | 27 | def represent_person(response) 28 | AfipPersonRepresenter.new(data: response) 29 | end 30 | 31 | def raise_response_error(response) 32 | error_message = Array 33 | .wrap(response[:error_constancia][:error]) 34 | .join('. ') 35 | 36 | raise Afip::ResponseError.new( 37 | error_message: error_message, 38 | message: message, 39 | response: response, 40 | ) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | 35 | number: 36 | precision: 37 | format: 38 | precision: 2 39 | separator: ',' 40 | delimiter: '.' 41 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Unagi Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/models/entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Entity < ApplicationRecord 4 | include Encryptable 5 | 6 | mount_uploader :logo, LogoUploader 7 | 8 | validates :name, presence: true 9 | validates :business_name, presence: true, uniqueness: true 10 | validates :cuit, presence: true, uniqueness: { case_sensitive: false } 11 | 12 | attr_encrypted :auth_token 13 | 14 | has_many :invoices, dependent: :destroy 15 | 16 | before_create :set_afip_data, :set_auth_token 17 | 18 | scope :by_name, -> { order(:name) } 19 | 20 | def as_json(options = {}) 21 | options[:only] = %i[id name business_name cuit csr] 22 | options[:methods] = [:logo_url] 23 | super 24 | end 25 | 26 | private 27 | 28 | def set_afip_data 29 | data = Afip::EntityDataGenerator.new(self).call 30 | 31 | self.private_key = data[:pkey] 32 | self.csr = data[:csr].to_s 33 | end 34 | 35 | def set_auth_token 36 | self.auth_token = SecureRandom.uuid 37 | end 38 | 39 | def logo_url 40 | return if logo.blank? 41 | 42 | "data:#{logo.file.content_type};base64,#{Base64.encode64(logo.file.read)}" 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/support/responses/sale_points_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 2 9 | CAE 10 | N 11 | NULL 12 | 13 | 14 | 15 | 3 16 | CAE 17 | N 18 | 20190108010858 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rails 2 | AllCops: 3 | Exclude: 4 | - 'db/migrate/*.rb' 5 | - 'db/schema.rb' 6 | - 'bin/*' 7 | - 'config/**/*' 8 | - 'test/**/*' 9 | - 'spec/**/*' 10 | - 'vendor/**/*' 11 | Layout/ArgumentAlignment: 12 | EnforcedStyle: with_fixed_indentation 13 | Layout/FirstArrayElementIndentation: 14 | EnforcedStyle: consistent 15 | Layout/MultilineMethodCallIndentation: 16 | EnforcedStyle: indented 17 | Metrics/AbcSize: 18 | Max: 30 19 | Exclude: 20 | - app/pdfs/invoice_pdf.rb 21 | - app/services/invoice/validator.rb 22 | Metrics/BlockLength: 23 | Exclude: 24 | - 'app/pdfs/*' 25 | Metrics/ClassLength: 26 | Max: 150 27 | Exclude: 28 | - 'app/pdfs/*' 29 | Metrics/MethodLength: 30 | Max: 20 31 | Exclude: 32 | - 'app/pdfs/*' 33 | Metrics/ParameterLists: 34 | Max: 6 35 | Style/AsciiComments: 36 | Enabled: false 37 | Style/Documentation: 38 | Enabled: false 39 | Style/TrailingCommaInArguments: 40 | EnforcedStyleForMultiline: comma 41 | Style/TrailingCommaInArrayLiteral: 42 | EnforcedStyleForMultiline: comma 43 | Style/TrailingCommaInHashLiteral: 44 | EnforcedStyleForMultiline: comma 45 | 46 | -------------------------------------------------------------------------------- /app/controllers/v1/static_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module V1 4 | class StaticController < ApplicationController 5 | def bill_types 6 | render json: StaticResource::BillTypes.new(entity).call 7 | end 8 | 9 | def concept_types 10 | render json: StaticResource::ConceptTypes.new(entity).call 11 | end 12 | 13 | def currencies 14 | render json: StaticResource::Currencies.new(entity).call 15 | end 16 | 17 | def document_types 18 | render json: StaticResource::DocumentTypes.new(entity).call 19 | end 20 | 21 | def iva_types 22 | render json: StaticResource::IvaTypes.new(entity).call 23 | end 24 | 25 | def sale_points 26 | render json: StaticResource::SalePoints.new(entity).call 27 | end 28 | 29 | def tax_types 30 | render json: StaticResource::TaxTypes.new(entity).call 31 | end 32 | 33 | def optionals 34 | render json: StaticResource::Optionals.new(entity).call 35 | end 36 | 37 | # rubocop:disable Naming/PredicateName 38 | def is_working 39 | head :ok 40 | end 41 | # rubocop:enable Naming/PredicateName 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/representers/generated_invoice_representer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GeneratedInvoiceRepresenter < OpenStruct 4 | include Representable::JSON 5 | 6 | delegate :authorization_code, to: :invoice, allow_nil: true 7 | delegate :bill_number, to: :invoice, allow_nil: true 8 | delegate :id, to: :invoice, prefix: :internal, allow_nil: true 9 | delegate :token, to: :invoice, allow_nil: true 10 | delegate :sale_point_id, to: :invoice, allow_nil: true 11 | 12 | property :bill 13 | property :bill_number 14 | property :cae 15 | property :cae_expiracy 16 | property :internal_id 17 | property :render_url 18 | property :sale_point_id 19 | property :token 20 | 21 | def initialize(bill:, cae_expiracy:, invoice:) 22 | super() 23 | 24 | self.bill = bill 25 | self.cae = invoice&.authorization_code 26 | self.cae_expiracy = cae_expiracy 27 | self.invoice = invoice 28 | self.render_url = build_render_url 29 | end 30 | 31 | private 32 | 33 | def build_render_url 34 | return unless token 35 | 36 | Rails.application.routes.url_helpers.export_invoice_url( 37 | token, 38 | format: :pdf, 39 | ) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.7.6-slim-buster 2 | 3 | #to fix: SSL_connect returned=1 errno=0 state=error: dh key too small in OpenSSL 4 | RUN sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/' /etc/ssl/openssl.cnf 5 | 6 | # Common dependencies 7 | RUN apt-get update -qq \ 8 | && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ 9 | build-essential \ 10 | gnupg2 \ 11 | curl \ 12 | lsb-release \ 13 | && apt-get clean \ 14 | && rm -rf /var/cache/apt/archives/* \ 15 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ 16 | && truncate -s 0 /var/log/*log 17 | 18 | RUN echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ 19 | && curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - 20 | RUN apt-get update -qq && apt-get install -y nodejs postgresql-client-12 libpq-dev 21 | 22 | ENV APP_HOME /app 23 | RUN mkdir $APP_HOME 24 | WORKDIR $APP_HOME 25 | 26 | RUN gem install bundler:2.2.25 27 | ADD Gemfile* $APP_HOME/ 28 | RUN bundle install 29 | 30 | COPY . ./ 31 | 32 | EXPOSE 3001 33 | 34 | ENTRYPOINT ["/app/docker/docker-entrypoint.sh"] 35 | CMD ["rails", "server", "-b", "0.0.0.0"] 36 | -------------------------------------------------------------------------------- /app/models/invoice_item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InvoiceItem < ApplicationRecord 4 | validates :bonus_percentage, presence: true 5 | validates :description, presence: true 6 | validates :quantity, presence: true 7 | validates :unit_price, presence: true 8 | 9 | belongs_to :invoice 10 | 11 | def total 12 | (subtotal * (1 - bonus_coefficient)).round(2) 13 | end 14 | 15 | def bonus_amount 16 | (subtotal * bonus_coefficient).round(2) 17 | end 18 | 19 | def iva_aliquot 20 | iva_record = StaticResource::IvaTypes.new(invoice.entity).call.find do |iva_type| 21 | iva_type[:id].to_i == iva_aliquot_id 22 | end 23 | 24 | iva_record ||= { id: '99', name: 'No gravado' } 25 | 26 | iva_record[:name] 27 | .gsub('%', '') 28 | .gsub(',', '.') 29 | .strip 30 | .to_f 31 | end 32 | 33 | def exempt? 34 | iva_aliquot_id == StaticResource::IvaTypes::EXEMPT_ID 35 | end 36 | 37 | def untaxed? 38 | iva_aliquot_id == StaticResource::IvaTypes::UNTAXED_ID 39 | end 40 | 41 | private 42 | 43 | def subtotal 44 | quantity * unit_price 45 | end 46 | 47 | def bonus_coefficient 48 | bonus_percentage / 100 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/models/entity_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/models/entity_support' 5 | 6 | describe Entity, type: :model do 7 | describe 'Basics' do 8 | it 'has a valid factory' do 9 | expect(build(:entity)).to be_valid 10 | end 11 | end 12 | 13 | describe 'Associations' do 14 | it { is_expected.to have_many(:invoices).dependent(:destroy) } 15 | end 16 | 17 | describe 'Validations' do 18 | context 'presence validations' do 19 | it { is_expected.to validate_presence_of(:business_name) } 20 | it { is_expected.to validate_presence_of(:cuit) } 21 | it { is_expected.to validate_presence_of(:name) } 22 | end 23 | 24 | context 'uniqueness validations' do 25 | subject { create(:entity) } 26 | 27 | it { is_expected.to validate_uniqueness_of(:business_name) } 28 | it { is_expected.to validate_uniqueness_of(:cuit).case_insensitive } 29 | end 30 | end 31 | 32 | describe '#as_json' do 33 | subject { create(:entity) } 34 | 35 | it 'returns a representation as JSON' do 36 | expect(subject.as_json.symbolize_keys) 37 | .to match_valid_format(EntitySupport::JSON_FORMAT) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | ruby '2.7.6' 6 | 7 | gem 'carrierwave', '~> 2.0' 8 | gem 'figaro' 9 | gem 'multi_json' 10 | gem 'pg' 11 | gem 'prawn' 12 | gem 'prawn-qrcode', '~> 0.5.2' 13 | gem 'prawn-table' 14 | gem 'puma' 15 | gem 'rails', '~> 6.1.7' 16 | gem 'redis' 17 | gem 'representable' 18 | gem 'savon' 19 | 20 | group :development, :test do 21 | gem 'pry' 22 | end 23 | 24 | group :development do 25 | gem 'listen' 26 | gem 'rubocop', require: false 27 | gem 'rubocop-rails', require: false 28 | gem 'spring' 29 | gem 'spring-watcher-listen', '~> 2.0.0' 30 | end 31 | 32 | group :test do 33 | gem 'factory_bot_rails' 34 | gem 'faker' 35 | gem 'rspec-rails' 36 | gem 'shoulda-matchers', '4.0.0.rc1' 37 | gem 'should_not' 38 | gem 'simplecov', require: false 39 | gem 'simplecov-lcov', require: false 40 | gem 'webmock' 41 | end 42 | 43 | group :production do 44 | gem 'capistrano', require: false 45 | gem 'capistrano3-puma', require: false 46 | gem 'capistrano-bundler', require: false 47 | gem 'capistrano-rails', require: false 48 | gem 'capistrano-rbenv', require: false 49 | gem 'capistrano-systemd' 50 | gem 'rack-cache', require: 'rack/cache' 51 | end 52 | -------------------------------------------------------------------------------- /db/migrate/20190227013709_change_iva_aliquot_in_invoice_items.rb: -------------------------------------------------------------------------------- 1 | class ChangeIvaAliquotInInvoiceItems < ActiveRecord::Migration[5.1] 2 | ID_FROM_RATE = { 3 | 0.0 => 3, 4 | 2.5 => 9, 5 | 5.0 => 8, 6 | 10.5 => 4, 7 | 21.0 => 5, 8 | 27.0 => 6 9 | }.freeze 10 | 11 | def up 12 | InvoiceItem.all.find_each do |item| 13 | id = ID_FROM_RATE[item.attributes['iva_aliquot'].to_f] 14 | 15 | unless id 16 | raise "No se encontró el ID asociado a la alícuota #{item.attributes['iva_aliquot']} del item #{item.id}" 17 | end 18 | 19 | item.update(iva_aliquot: id) 20 | end 21 | 22 | change_column :invoice_items, :iva_aliquot, :integer 23 | rename_column :invoice_items, :iva_aliquot, :iva_aliquot_id 24 | end 25 | 26 | def down 27 | change_column :invoice_items, :iva_aliquot_id, :decimal, precision: 5, scale: 2 28 | 29 | InvoiceItem.all.find_each do |item| 30 | rate = ID_FROM_RATE.key(item.iva_aliquot_id.to_i) 31 | 32 | unless rate 33 | raise "No se encontró la alícuota asociada al ID #{item.iva_aliquot_id} para el item #{item.id}" 34 | end 35 | 36 | item.update(iva_aliquot_id: rate) 37 | end 38 | 39 | rename_column :invoice_items, :iva_aliquot_id, :iva_aliquot 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails' 6 | # Pick the frameworks you want: 7 | require 'active_model/railtie' 8 | # require "active_job/railtie" 9 | require 'active_record/railtie' 10 | require 'action_controller/railtie' 11 | require 'action_mailer/railtie' 12 | # require "action_view/railtie" 13 | # require "action_cable/engine" 14 | # require "sprockets/railtie" 15 | require 'rails/test_unit/railtie' 16 | 17 | # Require the gems listed in Gemfile, including any gems 18 | # you've limited to :test, :development, or :production. 19 | Bundler.require(*Rails.groups) 20 | 21 | module AfipInvoices 22 | class Application < Rails::Application 23 | config.load_defaults 6.1 24 | 25 | config.time_zone = 'Buenos Aires' 26 | config.api_only = true 27 | 28 | config.autoload_paths += %W[#{config.root}/errors] 29 | config.autoload_paths += %W[#{config.root}/managers] 30 | config.autoload_paths += %W[#{config.root}/representers] 31 | config.autoload_paths += %W[#{config.root}/services] 32 | config.autoload_paths += %W[#{config.root}/uploaders] 33 | 34 | config.generators do |g| 35 | g.fixture_replacement :factory_bot, suffix_factory: 'factory' 36 | g.test_framework :rspec 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/services/static_resource/bill_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class BillTypes < Base 5 | SHORT_NAMES = { 6 | "Factura A": 'A', 7 | "Factura B": 'B', 8 | "Factura C": 'C', 9 | "Nota de Débito A": 'NDA', 10 | "Nota de Débito B": 'NDB', 11 | "Nota de Débito C": 'NDC', 12 | "Nota de Crédito A": 'NCA', 13 | "Nota de Crédito B": 'NCB', 14 | "Nota de Crédito C": 'NCC', 15 | "Recibos A": 'A', 16 | "Recibos B": 'B', 17 | "Recibo C": 'C', 18 | "Factura de Crédito electrónica MiPyMEs (FCE) A": 'FCEA', 19 | "Factura de Crédito electrónica MiPyMEs (FCE) B": 'FCEB', 20 | "Factura de Crédito electrónica MiPyMEs (FCE) C": 'FCEC', 21 | "Nota de Crédito electrónica MiPyMEs (FCE) A": 'NCEA', 22 | "Nota de Crédito electrónica MiPyMEs (FCE) B": 'NCEB', 23 | "Nota de Crédito electrónica MiPyMEs (FCE) C": 'NCEC', 24 | "Nota de Débito electrónica MiPyMEs (FCE) A": 'NDEA', 25 | "Nota de Débito electrónica MiPyMEs (FCE) B": 'NDEB', 26 | "Nota de Débito electrónica MiPyMEs (FCE) C": 'NDEC', 27 | }.freeze 28 | 29 | private 30 | 31 | def operation 32 | :fe_param_get_tipos_cbte 33 | end 34 | 35 | def resource 36 | :cbte_tipo 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/services/invoice/generator/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class Generator 5 | class Result 6 | attr_reader :afip_errors, :cae_expiracy, :errors, :invoice, :new_record, :success 7 | 8 | delegate :persisted?, to: :invoice, allow_nil: true 9 | 10 | def initialize(invoice:, cae_expiracy:, new_record:, errors:, afip_errors:, success:) 11 | @afip_errors = afip_errors || [] 12 | @cae_expiracy = cae_expiracy 13 | @errors = errors || [] 14 | @invoice = invoice 15 | @new_record = new_record 16 | @success = success 17 | end 18 | 19 | def represented_invoice 20 | @represented_invoice ||= GeneratedInvoiceRepresenter.new( 21 | bill: invoice&.receipt, 22 | cae_expiracy: cae_expiracy, 23 | invoice: invoice, 24 | ) 25 | end 26 | 27 | def status 28 | return :in_progress unless finished? 29 | 30 | new_record? ? :created : :existing 31 | end 32 | 33 | def with_errors? 34 | !success && (errors.any? || afip_errors.any?) 35 | end 36 | 37 | private 38 | 39 | def new_record? 40 | new_record 41 | end 42 | 43 | def finished? 44 | invoice && persisted? 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/support/services/invoice/validator_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative './generator_support' 4 | 5 | class Invoice 6 | class ValidatorSupport 7 | QUERY = Invoice::GeneratorSupport::PARAMS.deep_dup 8 | 9 | ASSOCIATED_INVOICE = { 10 | bill_type_id: '11', 11 | sale_point_id: '0001', 12 | number: '7', 13 | date: Date.yesterday.strftime(Invoice::Schema::DATE_FORMAT), 14 | }.freeze 15 | 16 | DATE_ATTRIBUTES = %i[ 17 | created_at 18 | due_date 19 | service_from 20 | service_to 21 | ].freeze 22 | 23 | AMOUNT_ATTRIBUTES = %i[ 24 | exempt_amount 25 | iva_amount 26 | net_amount 27 | tax_amount 28 | total_amount 29 | untaxed_amount 30 | ].freeze 31 | 32 | OPT_ATTRIBUTES = { 33 | alias: '7894561235', 34 | cbu: '0140590940090418135201', 35 | transmission: 'SCA', 36 | }.freeze 37 | 38 | DATE_FORMATS = [ 39 | '%Y/%m/%d', 40 | '%Y-%m-%d', 41 | '%Y/%m/%d', 42 | '%y/%m/%d', 43 | '%y-%m-%d', 44 | '%y/%m/%d', 45 | '%d/%m/%Y', 46 | '%d-%m-%Y', 47 | '%d/%m/%Y', 48 | '%d/%m/%y', 49 | '%d-%m-%y', 50 | '%d/%m/%y', 51 | '%d%m%y', 52 | '%d%m%Y', 53 | '%y%m%d', 54 | ].freeze 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/mocks/people_service_mock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PeopleServiceMock < AfipMock 4 | ENDPOINT = %r{/sr-padron/webservices/personaServiceA5}i 5 | 6 | def ws_wsdl 7 | WebMock 8 | .stub_request(:get, /#{ENDPOINT}\?wsdl$/i) 9 | .to_return(body: File.read('spec/support/responses/ws_sr_padron_a5_wsdl.xml')) 10 | end 11 | 12 | def natural_responsible_person 13 | stub_action( 14 | soap_action: :get_persona, 15 | response_body: File.read('spec/support/responses/natural_responsible_person_response.xml'), 16 | ) 17 | end 18 | 19 | def natural_single_taxpayer_person 20 | stub_action( 21 | soap_action: :get_persona, 22 | response_body: File.read('spec/support/responses/natural_single_taxpayer_person_response.xml'), 23 | ) 24 | end 25 | 26 | def legal_person 27 | stub_action( 28 | soap_action: :get_persona, 29 | response_body: File.read('spec/support/responses/legal_person_response.xml'), 30 | ) 31 | end 32 | 33 | def person_with_invalid_address 34 | stub_action( 35 | soap_action: :get_persona, 36 | response_body: File.read('spec/support/responses/person_with_invalid_address.xml'), 37 | ) 38 | end 39 | 40 | def person_not_found 41 | stub_action( 42 | soap_action: :get_persona, 43 | response_body: File.read('spec/support/responses/person_not_found_response.xml'), 44 | ) 45 | end 46 | 47 | private 48 | 49 | def endpoint 50 | ENDPOINT 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/mocks/afip_mock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'webmock' 4 | 5 | class AfipMock 6 | SOAP_HEADERS = { 7 | headers: { 8 | 'Content-Type' => 'text/xml;charset=UTF-8', 9 | }.freeze 10 | }.freeze 11 | 12 | def self.mock(method) 13 | mock_object.send(method) 14 | end 15 | 16 | def self.mock_login 17 | mock(:login_wsdl) 18 | mock(:login) 19 | end 20 | 21 | def login_wsdl 22 | WebMock 23 | .stub_request(:get, /LoginCms\?wsdl$/) 24 | .to_return(body: File.new('spec/support/responses/wsaa_wsdl.xml').read) 25 | end 26 | 27 | def login 28 | WebMock 29 | .stub_request(:post, /LoginCms$/) 30 | .to_return(body: File.new('spec/support/responses/login_response.xml').read) 31 | end 32 | 33 | class << self 34 | private 35 | 36 | def mock_object 37 | @mock_object ||= new 38 | end 39 | end 40 | 41 | private 42 | 43 | def stub_action(soap_action:, response_body:, status: 200) 44 | params = build_params(soap_action) 45 | 46 | WebMock 47 | .stub_request(:post, endpoint) 48 | .with(params) 49 | .to_return( 50 | status: status, 51 | body: response_body, 52 | headers: {content_type: 'text/xml;charset=UTF-8'}, 53 | ) 54 | end 55 | 56 | def build_params(soap_action) 57 | action = soap_action.to_s.delete('_') 58 | 59 | SOAP_HEADERS.deep_merge( 60 | body: /./, 61 | headers: {'SOAPAction' => /#{Regexp.quote(action)}/i}, 62 | ) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/support/responses/invoice_response.xml: -------------------------------------------------------------------------------- 1 | 38020308769608442018042031702002000300250420201803012018033120180420PES11 15021502 100210052000420A68166645934991CAE201804302018042016555810063Factura (CbteDesde igual a CbteHasta), DocTipo, DocNro, no se encuentra inscripto en condicion ACTIVA en el impuesto (IVA).11 2 | -------------------------------------------------------------------------------- /app/controllers/v1/entities_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module V1 4 | class EntitiesController < AdminController 5 | before_action :fetch_entity, only: %i[show update] 6 | 7 | def create 8 | manager = EntityManager.new(params: create_params) 9 | 10 | if manager.create_or_update 11 | render json: GeneratedEntityRepresenter.new(manager.entity), 12 | status: :created 13 | else 14 | render json: { errors: entity.errors }, status: :bad_request 15 | end 16 | end 17 | 18 | def update 19 | manager = EntityManager.new(params: update_params, entity: @entity) 20 | 21 | if manager.create_or_update 22 | head :no_content 23 | else 24 | render json: { error: 'Error al actualizar certificado' }, 25 | status: :bad_request 26 | end 27 | end 28 | 29 | def index 30 | render json: EntityRepresenter 31 | .for_collection 32 | .new(Entity.by_name) 33 | end 34 | 35 | def show 36 | render json: @entity 37 | end 38 | 39 | private 40 | 41 | def fetch_entity 42 | @entity = Entity.find(params[:id]) 43 | end 44 | 45 | def create_params 46 | params.require(:entity).permit( 47 | :name, 48 | :business_name, 49 | :cuit, 50 | logo: %i[filename content_type data], 51 | ) 52 | end 53 | 54 | def update_params 55 | params.fetch(:entity, {}).permit( 56 | :certificate, 57 | logo: %i[filename content_type data], 58 | ) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/shared_examples/shared_examples_for_controllers.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/rails' 2 | 3 | RSpec.shared_examples 'HTTP 100 response' do 4 | it 'returns an HTTP 100 response' do 5 | subject 6 | 7 | expect(response).to have_http_status(:continue) 8 | end 9 | end 10 | 11 | RSpec.shared_examples 'HTTP 200 response' do 12 | it 'returns an HTTP 200 response' do 13 | subject 14 | 15 | expect(response).to have_http_status(:ok) 16 | end 17 | end 18 | 19 | RSpec.shared_examples 'HTTP 201 response' do 20 | it 'returns an HTTP 201 response' do 21 | subject 22 | 23 | expect(response).to have_http_status(:created) 24 | end 25 | end 26 | 27 | RSpec.shared_examples 'HTTP 204 response' do 28 | it 'returns an HTTP 204 response' do 29 | subject 30 | 31 | expect(response).to have_http_status(:no_content) 32 | end 33 | end 34 | 35 | RSpec.shared_examples 'HTTP 400 response' do 36 | it 'returns an HTTP 400 response' do 37 | subject 38 | 39 | expect(response).to have_http_status(:bad_request) 40 | end 41 | end 42 | 43 | RSpec.shared_examples 'HTTP 404 response' do 44 | it 'returns an HTTP 404 response' do 45 | subject 46 | 47 | expect(response).to have_http_status(:not_found) 48 | end 49 | end 50 | 51 | RSpec.shared_examples 'HTTP 502 response' do 52 | it 'returns an HTTP 502 response' do 53 | subject 54 | 55 | expect(response).to have_http_status(:bad_gateway) 56 | end 57 | end 58 | 59 | RSpec.shared_examples 'PDF response' do 60 | it 'renders PDF' do 61 | subject 62 | 63 | expect(response.headers['Content-Type']).to eq('application/pdf') 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /app/services/static_resource/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StaticResource 4 | class Base 5 | TTL = 12.hours 6 | 7 | def initialize(entity) 8 | @entity = entity 9 | @afip = Afip::InvoicesService.new(entity) 10 | end 11 | 12 | def call 13 | return Rails.cache.read(cache_key) if Rails.cache.exist?(cache_key) 14 | 15 | response = format_response @afip.call(operation) 16 | 17 | return response if response.is_a?(Hash) && response.key?(:error) 18 | 19 | Rails.cache.write(cache_key, response, expires_in: TTL) 20 | response 21 | end 22 | 23 | def find(id) 24 | record = call.find { |type| type[:id].to_i == id.to_i } 25 | 26 | return nil unless record 27 | 28 | record[:name] 29 | rescue Afip::BaseError 30 | false 31 | end 32 | 33 | private 34 | 35 | def operation 36 | raise NotImplementedError 37 | end 38 | 39 | def resource 40 | raise NotImplementedError 41 | end 42 | 43 | def format_response(response) 44 | results = response["#{operation}_response".to_sym]["#{operation}_result".to_sym] 45 | 46 | return error_response(results[:errors]) if results[:errors] 47 | 48 | resources = Array.wrap(results[:result_get][resource]) 49 | 50 | resources.map! { |item| format_item(item) } 51 | 52 | resources.compact 53 | end 54 | 55 | def format_item(item) 56 | { id: item[:id], name: item[:desc] } 57 | end 58 | 59 | def error_response(errors) 60 | { error: errors[:err][:msg] } 61 | end 62 | 63 | def cache_key 64 | "#{@entity.cuit}/#{operation}" 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /app/services/invoice/data_formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class DataFormatter 5 | DATETIME_FORMAT = '%Y%m%d%H%M%S' 6 | 7 | def initialize(data) 8 | @data = data.dup 9 | end 10 | 11 | def call 12 | format_bill 13 | format_amounts 14 | format_dates 15 | 16 | @data 17 | end 18 | 19 | private 20 | 21 | def format_bill 22 | @data[:sale_point_id] = @data[:sale_point_id].to_s.rjust(4, '0') 23 | @data[:bill_number] = @data[:bill_number].to_s.rjust(8, '0') 24 | end 25 | 26 | def format_amounts 27 | %i[iva taxes].each do |key| 28 | @data[key] ||= [] 29 | 30 | @data[key].each do |item| 31 | item[:net_amount] = item[:net_amount].to_f 32 | item[:total_amount] = item[:total_amount].to_f 33 | 34 | item[:rate] = item[:rate].to_f if item[:rate] 35 | end 36 | end 37 | 38 | @data[:tax_amount] = @data[:tax_amount].to_f if @data[:tax_amount] 39 | end 40 | 41 | def format_dates 42 | %i[service_from service_to due_date expiracy_date].each do |key| 43 | @data[key] = format_date(@data[key]) 44 | end 45 | 46 | @data[:created_at] = format_datetime(@data[:created_at]) 47 | end 48 | 49 | def format_date(date) 50 | return if date.blank? 51 | 52 | Date 53 | .parse(date, Invoice::Schema::DATE_FORMAT) 54 | .strftime('%d/%m/%Y') 55 | end 56 | 57 | def format_datetime(datetime) 58 | return if datetime.blank? 59 | 60 | DateTime 61 | .parse(datetime, DATETIME_FORMAT) 62 | .strftime('%d/%m/%Y %H:%M:%S') 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/routing/entity_routing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe 'routes for Entities' do 4 | describe 'without version' do 5 | let(:prefix_url) { '/entities' } 6 | 7 | it 'routes GET /entities to index' do 8 | expect(get(prefix_url)).to route_to('v1/entities#index') 9 | end 10 | 11 | it 'routes POST /entities to create' do 12 | expect(post(prefix_url)).to route_to('v1/entities#create') 13 | end 14 | 15 | it 'routes GET /entities/:id to show' do 16 | expect(get("#{prefix_url}/1")).to route_to(controller: 'v1/entities', action: 'show', id: '1') 17 | end 18 | 19 | it 'routes PUT/PATCH /entities/:id to update' do 20 | expect(patch("#{prefix_url}/1")).to route_to(controller: 'v1/entities', action: 'update', id: '1') 21 | expect(put("#{prefix_url}/1")).to route_to(controller: 'v1/entities', action: 'update', id: '1') 22 | end 23 | end 24 | 25 | describe 'v1' do 26 | let(:prefix_url) { '/v1/entities' } 27 | 28 | it 'routes GET /v1/entities to index' do 29 | expect(get(prefix_url)).to route_to('v1/entities#index') 30 | end 31 | 32 | it 'routes POST /v1/entities to create' do 33 | expect(post(prefix_url)).to route_to('v1/entities#create') 34 | end 35 | 36 | it 'routes GET /v1/entities/:id to show' do 37 | expect(get("#{prefix_url}/1")).to route_to(controller: 'v1/entities', action: 'show', id: '1') 38 | end 39 | 40 | it 'routes PUT/PATCH /v1/entities to update' do 41 | expect(patch("#{prefix_url}/1")).to route_to(controller: 'v1/entities', action: 'update', id: '1') 42 | expect(put("#{prefix_url}/1")).to route_to(controller: 'v1/entities', action: 'update', id: '1') 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::API 4 | include ActionController::HttpAuthentication::Token::ControllerMethods 5 | include ActionController::MimeResponds 6 | 7 | rescue_from ActiveRecord::RecordNotFound, with: :render_not_found 8 | 9 | rescue_from Afip::InvalidRequestError, 10 | with: :render_external_api_error 11 | 12 | rescue_from Afip::UnsuccessfulResponseError, 13 | with: :render_external_api_error 14 | 15 | rescue_from Afip::InvalidResponseError, 16 | with: :render_external_api_error 17 | 18 | rescue_from Afip::ResponseError, 19 | with: :render_external_api_error 20 | 21 | rescue_from Afip::TimeoutError, 22 | with: :render_external_api_error 23 | 24 | rescue_from Afip::UnexpectedError, 25 | with: :render_external_api_error 26 | 27 | rescue_from ActiveSupport::MessageEncryptor::InvalidMessage, 28 | with: :render_invalid_token 29 | 30 | before_action :authenticate 31 | 32 | protected 33 | 34 | def render_not_found 35 | render json: { error: 'Recurso no encontrado' }, status: :not_found 36 | end 37 | 38 | def render_external_api_error(error) 39 | render json: { error: error }, status: :bad_gateway 40 | end 41 | 42 | def authenticate 43 | authenticate_or_request_with_http_token do |token, _options| 44 | @entity = Auth::TokenValidator.new(token).call 45 | end 46 | end 47 | 48 | def request_http_token_authentication(_realm = 'Application', _msg = nil) 49 | render_invalid_token 50 | end 51 | 52 | def render_invalid_token 53 | render json: { error: 'Token incorrecto' }, status: :unauthorized 54 | end 55 | 56 | attr_reader :entity 57 | end 58 | -------------------------------------------------------------------------------- /spec/support/responses/tax_types_response.xml: -------------------------------------------------------------------------------- 1 | 1Impuestos nacionales20100917NULL2Impuestos provinciales20100917NULL3Impuestos municipales20100917NULL4Impuestos Internos20100917NULL99Otro20100917NULL5IIBB20170719NULL6Percepción de IVA20170719NULL7Percepción de IIBB20170719NULL8Percepciones por Impuestos Municipales20170719NULL9Otras Percepciones20170719NULL13Percepción de IVA a no Categorizado20170719NULL 2 | -------------------------------------------------------------------------------- /.github/workflows/rspec.yml: -------------------------------------------------------------------------------- 1 | name: Test - Rspec 2 | on: 3 | pull_request: 4 | pull_request_review: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | rspec: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | 15 | services: 16 | postgres: 17 | image: postgres:13-alpine 18 | env: 19 | POSTGRES_USER: postgres 20 | POSTGRES_DB: afip_test 21 | POSTGRES_PASSWORD: postgres 22 | options: >- 23 | --health-cmd pg_isready 24 | --health-interval 10s 25 | --health-timeout 5s 26 | --health-retries 5 27 | ports: 28 | - 5432:5432 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: 2.7.6 35 | bundler-cache: true 36 | - name: Install dependencies and run RSpec 37 | env: 38 | AUTH_TOKEN: aaaaaa-bbbbb-ccccc-ddddd 39 | DATABASE_HOST: localhost 40 | DATABASE_USER: postgres 41 | DATABASE_PASS: postgres 42 | RAILS_ENV: test 43 | SECRET_KEY_BASE: "abcdef" 44 | ENCRYPTION_SERVICE_SALT: ",A3\x89\xA8\x19k\"\e[\xD8\x02\x9BK\xF5\xB2^\xC8\xDF\x8F\xAF\x84P\xB1\xFB\xA99\xA9\x16\xFA\xD5 " 45 | LOGIN_URL: ${{ secrets.LOGIN_URL }} 46 | WSFE_URL: ${{ secrets.WSFE_URL }} 47 | PADRON_A5_URL: ${{ secrets.PADRON_A5_URL }} 48 | run: | 49 | gem install bundler 50 | bundle install --jobs 4 --retry 3 51 | bundle exec rake db:create 52 | bundle exec rake db:schema:load 53 | bundle exec rspec 54 | - uses: actions/upload-artifact@v2 55 | with: 56 | name: "Test Coverage" 57 | path: coverage/ 58 | - name: Coveralls 59 | uses: coverallsapp/github-action@master 60 | with: 61 | github-token: ${{ secrets.GITHUB_TOKEN }} 62 | -------------------------------------------------------------------------------- /app/representers/afip_person_representer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AfipPersonRepresenter < OpenStruct 4 | include Representable::JSON 5 | 6 | IVA_TAX_ID = 30 7 | 8 | attr_reader :data 9 | 10 | property :address 11 | property :category 12 | property :city 13 | property :full_address 14 | property :name 15 | property :state 16 | property :zipcode 17 | 18 | def initialize(data:) 19 | @data = data 20 | 21 | super() 22 | 23 | self.address = personal_data[:domicilio_fiscal][:direccion] 24 | self.category = build_category 25 | self.city = personal_data[:domicilio_fiscal][:localidad] 26 | self.full_address = build_full_address 27 | self.name = build_name 28 | self.state = personal_data[:domicilio_fiscal][:descripcion_provincia] 29 | self.zipcode = personal_data[:domicilio_fiscal][:cod_postal] 30 | end 31 | 32 | private 33 | 34 | def personal_data 35 | data[:datos_generales] 36 | end 37 | 38 | def build_full_address 39 | "#{personal_data[:domicilio_fiscal][:direccion]} "\ 40 | "#{personal_data[:domicilio_fiscal][:localidad]}, "\ 41 | "#{personal_data[:domicilio_fiscal][:descripcion_provincia]} " 42 | end 43 | 44 | def build_name 45 | return personal_data[:razon_social] if personal_data[:tipo_persona] == 'JURIDICA' 46 | 47 | "#{personal_data[:nombre]} #{personal_data[:apellido]}" 48 | end 49 | 50 | def build_category 51 | return 'Monotributista' if data.key?(:datos_monotributo) 52 | 53 | if data[:datos_regimen_general].present? 54 | taxes = Array.wrap(data[:datos_regimen_general][:impuesto]) 55 | return 'Responsable inscripto' if taxes.any? { |x| x[:id_impuesto].to_i == IVA_TAX_ID } 56 | end 57 | 58 | Rails.logger.warn 'No fue posible determinar la categoría de la persona. '\ 59 | "Respuesta recibida: #{::JSON.dump(data)}" 60 | 61 | nil 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/support/responses/create_invoice_error_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Homologacion - efa 6 | 2019-01-08T01:08:58.701162-03:00 7 | 2.12.10.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 20376793126 15 | 1 16 | 1 17 | 20190108010858 18 | 1 19 | R 20 | N 21 | 22 | 23 | 24 | 1 25 | 80 26 | 20379452257 27 | 35 28 | 35 29 | 20190105 30 | R 31 | 32 | 33 | 34 | 35 | 36 | 37 | 10016 38 | El numero o fecha del comprobante no se corresponde con el proximo a autorizar. Consultar metodo FECompUltimoAutorizado. 39 | 40 | 41 | 666 42 | Mensaje de error de prueba 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/services/afip/manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Afip 4 | class Manager 5 | def initialize(entity) 6 | @entity = entity 7 | @afip = InvoicesService.new(entity) 8 | end 9 | 10 | def request_invoice(params) 11 | message = Invoice::QueryBuilder 12 | .new(params.merge(cuit: @entity.cuit)) 13 | .call 14 | 15 | data = @afip.call(:fecae_solicitar, message) 16 | 17 | { 18 | response: request_invoice_response(data), 19 | errors: request_invoice_errors(data), 20 | } 21 | end 22 | 23 | def find_invoice(invoice) 24 | Invoice::Finder.new( 25 | params: { bill_number: invoice.bill_number, 26 | sale_point_id: invoice.sale_point_id, 27 | bill_type_id: invoice.bill_type_id }, 28 | entity: @entity, 29 | ).run 30 | end 31 | 32 | def next_bill_number(sale_point_id, bill_type_id) 33 | response = @afip.call(:fe_comp_ultimo_autorizado, { 34 | 'PtoVta' => sale_point_id, 35 | 'CbteTipo' => bill_type_id, 36 | }) 37 | 38 | response[:fe_comp_ultimo_autorizado_response][:fe_comp_ultimo_autorizado_result][:cbte_nro].to_i + 1 39 | end 40 | 41 | private 42 | 43 | def request_invoice_response(data) 44 | data[:fecae_solicitar_response][:fecae_solicitar_result][:fe_det_resp][:fecae_det_response] 45 | end 46 | 47 | def request_invoice_errors(data) 48 | errors = Array.wrap(data.dig(:fecae_solicitar_response, :fecae_solicitar_result, :errors, 49 | :err)).map do |error| 50 | "#{error[:msg]} (error #{error[:code]})" 51 | end 52 | 53 | errors += Array.wrap(request_invoice_response(data)[:observaciones]).flat_map do |error| 54 | Array.wrap(error[:obs]).map do |item| 55 | "#{item[:msg]} (error #{item[:code]})" 56 | end 57 | end 58 | 59 | errors 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/models/invoice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice < ApplicationRecord 4 | include Invoiceable 5 | 6 | has_secure_token 7 | 8 | validates :authorization_code, presence: true 9 | 10 | belongs_to :entity 11 | has_many :associated_invoices, dependent: :destroy 12 | has_many :items, class_name: 'InvoiceItem', dependent: :destroy 13 | 14 | def qr_code 15 | invoice = Invoice::Finder.new(invoice: self, entity: entity).run 16 | # Version of the format of the bill (1 character) 17 | # Emission date of the bill (8 characters) - YYYYMMDD 18 | # C.U.I.T. of the issuer of the bill (11 characters) 19 | # Sale point (5 characters) 20 | # Bill type code (3 characters) 21 | # Bill number (8 characters) 22 | # Total amount (15 characters) 23 | # Currency id(3 characters) 24 | # Quotation in pesos (19 characters) 25 | # Recipient type id (2 characters) 26 | # Recipient number (20 characters) 27 | # Authorization type code 28 | # Authorization Code (C.A.I.) (14 characters) 29 | { 30 | 'ver' => invoice[:concept_type_id].to_i, 31 | 'fecha' => emission_date.strftime('%Y-%m-%d'), 32 | 'cuit' => entity.cuit.to_i, 33 | 'ptoVta' => sale_point_id.to_i, 34 | 'tipoCmp' => bill_type_id, 35 | 'nroCmp' => bill_number.to_i, 36 | 'importe' => invoice[:total_amount].to_f, 37 | 'moneda' => invoice[:currency_id], 38 | 'ctz' => invoice[:quotation].to_f, 39 | 'tipoDocRec' => invoice[:recipient_type_id].to_i, 40 | 'nroDocRec' => invoice[:recipient_number].to_i, 41 | 'tipoCodAut' => invoice[:emission_type] == 'CAE' ? 'E' : 'A', 42 | 'codAut' => authorization_code.to_i, 43 | }.to_json 44 | end 45 | 46 | def fce? 47 | Invoice::Schema::ELECTRONIC_CREDIT_INVOICES_IDS 48 | .map(&:to_i) 49 | .include?(bill_type_id) 50 | end 51 | 52 | def note? 53 | Invoice::Schema::NOTES_IDS.map(&:to_i).include?(bill_type_id) || 54 | Invoice::Schema::ELECTRONIC_NOTES_IDS.map(&:to_i).include?(bill_type_id) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /app/services/invoice/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class Schema 5 | CONCEPTS = { 6 | products: 1, 7 | services: 2, 8 | products_and_services: 3, 9 | }.freeze 10 | 11 | REQUIRED_ATTRIBUTES = { 12 | sale_point_id: 'Punto de venta', 13 | concept_type_id: 'Tipo de concepto', 14 | recipient_type_id: 'Tipo de documento de comprador', 15 | recipient_number: 'Número de identificación de comprador', 16 | bill_type_id: 'Tipo de comprobante', 17 | net_amount: 'Importe neto gravado', 18 | untaxed_amount: 'Importe neto no gravado', 19 | exempt_amount: 'Importe exento', 20 | iva_amount: 'Importe total de IVA', 21 | tax_amount: 'Importe total de tributos', 22 | total_amount: 'Importe total', 23 | }.freeze 24 | 25 | NUMERIC_ATTRIBUTES = REQUIRED_ATTRIBUTES.slice( 26 | :net_amount, 27 | :untaxed_amount, 28 | :exempt_amount, 29 | :iva_amount, 30 | :tax_amount, 31 | :total_amount, 32 | ).freeze 33 | 34 | ITEM_REQUIRED_ATTRIBUTES = { 35 | description: 'Descripción', 36 | unit_price: 'Precio unitario', 37 | iva_aliquot_id: 'Alícuota de IVA', 38 | }.freeze 39 | 40 | ITEM_NUMERIC_ATTRIBUTES = { 41 | quantity: 'Cantidad', 42 | unit_price: 'Precio unitario', 43 | bonus_percentage: 'Porcentaje de bonificación', 44 | iva_aliquot_id: 'Alícuota de IVA', 45 | }.freeze 46 | 47 | ASSOCIATED_INVOICE_ATTRIBUTES = { 48 | bill_type_id: 'Tipo de comprobante', 49 | sale_point_id: 'Punto de venta', 50 | number: 'Número de comprobante', 51 | date: 'Fecha del comprobante', 52 | }.freeze 53 | 54 | DATE_REGEX = /^(19|20)\d\d(0[1-9]|1[012])(0[1-9]|[12][0-9]|3[01])$/.freeze 55 | DATE_FORMAT = '%Y%m%d' 56 | FLOAT_REGEX = /^[0-9]*\.?[0-9]+$/.freeze 57 | 58 | ELECTRONIC_CREDIT_INVOICES_IDS = %w[201 206 211].freeze 59 | ELECTRONIC_NOTES_IDS = %w[202 203 207 208 212 213].freeze 60 | NOTES_IDS = %w[2 3 7 8 12 13 52 53].freeze 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/support/responses/login_response.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="yes"?> 2 | <loginTicketResponse version="1"> 3 | <header> 4 | <source>CN=wsaahomo, O=AFIP, C=AR, SERIALNUMBER=CUIT 33693450239</source> 5 | <destination>SERIALNUMBER=CUIT 20376793126, CN=batman</destination> 6 | <uniqueId>1861493915</uniqueId> 7 | <generationTime>2018-04-06T15:09:37.563-03:00</generationTime> 8 | <expirationTime>2018-04-07T03:09:37.563-03:00</expirationTime> 9 | </header> 10 | <credentials> 11 | <token>PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9InllcyI/Pgo8c3NvIHZlcnNpb249IjIuMCI+CiAgICA8aWQgc3JjPSJDTj13c2FhaG9tbywgTz1BRklQLCBDPUFSLCBTRVJJQUxOVU1CRVI9Q1VJVCAzMzY5MzQ1MDIzOSIgZHN0PSJDTj13c2ZlLCBPPUFGSVAsIEM9QVIiIHVuaXF1ZV9pZD0iMzQ4NTQ1MjE2NSIgZ2VuX3RpbWU9IjE1MjMwMzgxMTciIGV4cF90aW1lPSIxNTIzMDgxMzc3Ii8+CiAgICA8b3BlcmF0aW9uIHR5cGU9ImxvZ2luIiB2YWx1ZT0iZ3JhbnRlZCI+CiAgICAgICAgPGxvZ2luIGVudGl0eT0iMzM2OTM0NTAyMzkiIHNlcnZpY2U9IndzZmUiIHVpZD0iU0VSSUFMTlVNQkVSPUNVSVQgMjAzNzY3OTMxMjYsIENOPWJhdG1hbiIgYXV0aG1ldGhvZD0iY21zIiByZWdtZXRob2Q9IjIyIj4KICAgICAgICAgICAgPHJlbGF0aW9ucz4KICAgICAgICAgICAgICAgIDxyZWxhdGlvbiBrZXk9IjIwMzc2NzkzMTI2IiByZWx0eXBlPSI0Ii8+CiAgICAgICAgICAgIDwvcmVsYXRpb25zPgogICAgICAgIDwvbG9naW4+CiAgICA8L29wZXJhdGlvbj4KPC9zc28+Cg==</token> 12 | <sign>lOc0Zqwik+3aR5iIgbWQPvG/uNr8aIlUKGnMH7RRvGKyOetAqFdJFe42a1IdhnVhoPnFgacJ/aPKANgoodkV9UiQrq90UBJSVzOWQXUMwkK+tVP3JqgrsIlVBXF2qI6pmpIF4O/yUlL+CVBwbeamVyga5sz77qhpt1POSp3nkk0=</sign> 13 | </credentials> 14 | </loginTicketResponse> 15 | 16 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # The test environment is used exclusively to run your application's 7 | # test suite. You never need to work with it otherwise. Remember that 8 | # your test database is "scratch space" for the test suite and is wiped 9 | # and recreated between test runs. Don't rely on the data there! 10 | config.cache_classes = true 11 | 12 | config.read_encrypted_secrets = true 13 | 14 | # Do not eager load code on boot. This avoids loading your whole application 15 | # just for the purpose of running a single test. If you are using a tool that 16 | # preloads Rails for running tests, you may have to set it to true. 17 | config.eager_load = false 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=#{1.hour.seconds.to_i}", 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | 29 | # Raise exceptions instead of rendering exception templates. 30 | config.action_dispatch.show_exceptions = false 31 | 32 | # Disable request forgery protection in test environment. 33 | config.action_controller.allow_forgery_protection = false 34 | config.action_mailer.perform_caching = false 35 | 36 | # Tell Action Mailer not to deliver emails to the real world. 37 | # The :test delivery method accumulates sent emails in the 38 | # ActionMailer::Base.deliveries array. 39 | config.action_mailer.delivery_method = :test 40 | 41 | # Print deprecation notices to the stderr. 42 | config.active_support.deprecation = :stderr 43 | 44 | # Raises error for missing translations 45 | # config.action_view.raise_on_missing_translations = true 46 | Rails.application.routes.default_url_options[:host] = 'http://localhost:3000' 47 | end 48 | -------------------------------------------------------------------------------- /app/uploaders/logo_uploader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class LogoUploader < CarrierWave::Uploader::Base 4 | # Include RMagick or MiniMagick support: 5 | # include CarrierWave::RMagick 6 | # include CarrierWave::MiniMagick 7 | 8 | configure do |config| 9 | config.remove_previously_stored_files_after_update = false 10 | end 11 | 12 | # Choose what kind of storage to use for this uploader: 13 | storage :file 14 | # storage :fog 15 | 16 | # Override the directory where uploaded files will be stored. 17 | # This is a sensible default for uploaders that are meant to be mounted: 18 | def store_dir 19 | "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" 20 | end 21 | 22 | # Provide a default URL as a default if there hasn't been a file uploaded: 23 | # def default_url(*args) 24 | # # For Rails 3.1+ asset pipeline compatibility: 25 | # # ActionController::Base.helpers.asset_path("fallback/" + [version_name, "default.png"].compact.join('_')) 26 | # 27 | # "/images/fallback/" + [version_name, "default.png"].compact.join('_') 28 | # end 29 | 30 | # Process files as they are uploaded: 31 | # process scale: [200, 300] 32 | # 33 | # def scale(width, height) 34 | # # do something 35 | # end 36 | 37 | # Create different versions of your uploaded files: 38 | # version :thumb do 39 | # process resize_to_fit: [50, 50] 40 | # end 41 | 42 | # Add a white list of extensions which are allowed to be uploaded. 43 | # For images you might use something like this: 44 | # def extension_whitelist 45 | # %w(jpg jpeg gif png) 46 | # end 47 | 48 | # Override the filename of the uploaded files: 49 | # Avoid using model.id or version_name here, see uploader/store.rb for details. 50 | # def filename 51 | # "something.jpg" if original_filename 52 | # end 53 | def filename 54 | "#{secure_token}.#{file.extension}" if original_filename.present? 55 | end 56 | 57 | protected 58 | 59 | def secure_token 60 | var = :"@#{mounted_as}_secure_token" 61 | model.instance_variable_get(var) or model.instance_variable_set(var, SecureRandom.uuid) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/controllers/v1/afip_people_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'shared_examples/shared_examples_for_controllers' 5 | require 'support/controllers/afip_people_controller_support' 6 | 7 | describe V1::AfipPeopleController, type: :controller do 8 | let(:entity) { create(:entity) } 9 | 10 | before do 11 | AfipMock.mock_login 12 | PeopleServiceMock.mock(:ws_wsdl) 13 | 14 | request.headers['HTTP_AUTHORIZATION'] = "Token token=\"#{entity.auth_token}\"" 15 | end 16 | 17 | describe 'GET show' do 18 | subject { get :show, params: { cuit: '20201797064' } } 19 | 20 | context 'when person exists' do 21 | before do 22 | PeopleServiceMock.mock(:natural_responsible_person) 23 | end 24 | 25 | it_behaves_like 'HTTP 200 response' 26 | 27 | it 'renders person details' do 28 | subject 29 | 30 | data = JSON.parse(response.body).symbolize_keys 31 | 32 | expect(data).not_to be_empty 33 | expect(data).to match_valid_format( 34 | AfipPeopleControllerSupport::RESPONSE_FORMAT, 35 | ) 36 | end 37 | end 38 | 39 | context 'when person cannot be found' do 40 | before do 41 | PeopleServiceMock.mock(:person_not_found) 42 | end 43 | 44 | it_behaves_like 'HTTP 502 response' 45 | 46 | it 'renders an error' do 47 | get :show, params: { cuit: '20201797064' } 48 | 49 | data = JSON.parse(response.body) 50 | 51 | expect(data).not_to be_empty 52 | expect(data['error']).to include('No existe persona con ese Id') 53 | end 54 | end 55 | 56 | context 'when a response with error is returned from the external service' do 57 | before do 58 | PeopleServiceMock.mock(:person_with_invalid_address) 59 | end 60 | 61 | it_behaves_like 'HTTP 502 response' 62 | 63 | it 'renders an error' do 64 | get :show, params: { cuit: '20201797064' } 65 | 66 | parsed_body = JSON.parse(response.body) 67 | 68 | expect(parsed_body).not_to be_empty 69 | expect(parsed_body['error']).not_to be_nil 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/routing/invoice_routing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe 'routes for Invoice' do 4 | describe 'without version' do 5 | let(:prefix_url) { '/invoices' } 6 | 7 | it 'routes GET /invoices to index' do 8 | expect(get(prefix_url)).to route_to('v1/invoices#index') 9 | end 10 | 11 | it 'routes POST /invoices to create' do 12 | expect(post(prefix_url)).to route_to('v1/invoices#create') 13 | end 14 | 15 | it 'routes GET /invoices/:id to show' do 16 | expect(get("#{prefix_url}/1")).to route_to(controller: 'v1/invoices', action: 'show', id: '1') 17 | end 18 | 19 | it 'routes GET /invoices/details to details' do 20 | expect(get("#{prefix_url}/details")).to route_to('v1/invoices#details') 21 | end 22 | 23 | it 'routes GET /invoices/:id/export to export' do 24 | expect(get("#{prefix_url}/1/export")).to route_to(controller: 'v1/invoices', action: 'export', id: '1') 25 | end 26 | 27 | it 'routes POST /invoices/export_preview to export_preview' do 28 | expect(post("#{prefix_url}/export_preview")).to route_to('v1/invoices#export_preview') 29 | end 30 | end 31 | 32 | describe 'v1' do 33 | let(:prefix_url) { '/v1/invoices' } 34 | 35 | it 'routes GET /v1/invoices to index' do 36 | expect(get(prefix_url)).to route_to('v1/invoices#index') 37 | end 38 | 39 | it 'routes POST /v1/invoices to create' do 40 | expect(post(prefix_url)).to route_to('v1/invoices#create') 41 | end 42 | 43 | it 'routes GET /v1/invoices/:id to show' do 44 | expect(get("#{prefix_url}/1")).to route_to(controller: 'v1/invoices', action: 'show', id: '1') 45 | end 46 | 47 | it 'routes GET /v1/invoices/details to details' do 48 | expect(get("#{prefix_url}/details")).to route_to('v1/invoices#details') 49 | end 50 | 51 | it 'routes GET /v1/invoices/:id/export to export' do 52 | expect(get("#{prefix_url}/1/export")).to route_to(controller: 'v1/invoices', action: 'export', id: '1') 53 | end 54 | 55 | it 'routes POST /v1/invoices/export_preview to export_preview' do 56 | expect(post("#{prefix_url}/export_preview")).to route_to('v1/invoices#export_preview') 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/support/responses/natural_responsible_person_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RAZONAR 7 | 8 | 4500 9 | JUJUY 10 | SAN MARTIN 8 11 | 6 12 | ALTO DE LA LOMA 13 | FISCAL 14 | 15 | ACTIVO 16 | 20188192514 17 | 12 18 | MARTINCHUS 19 | CUIT 20 | FISICA 21 | 22 | 23 | 24 | CULTIVO DE MANZANA Y PERA 25 | 12311 26 | 883 27 | 1 28 | 201311 29 | 30 | 31 | GANANCIAS PERSONAS FISICAS 32 | 11 33 | 200606 34 | 35 | 36 | GANANCIA MINIMA PRESUNTA 37 | 25 38 | 201101 39 | 40 | 41 | IVA 42 | 30 43 | 200606 44 | 45 | 46 | 47 | 2018-10-03T11:13:27.139-03:00 48 | awshomo.afip.gov.ar 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/services/invoice/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class Builder 5 | def initialize(params, entity) 6 | @params = params 7 | @entity = entity 8 | 9 | @params[:associated_invoices] ||= [] 10 | @params[:taxes] ||= [] 11 | @params[:iva] ||= [] 12 | @params[:created_at] ||= Time 13 | .zone 14 | .now 15 | .strftime(Invoice::Schema::DATE_FORMAT) 16 | end 17 | 18 | def call 19 | build_invoice 20 | 21 | Invoice::RecipientLoader.new(@invoice).call(@params[:recipient_number]) 22 | 23 | build_items 24 | build_associated_invoices 25 | 26 | @invoice 27 | end 28 | 29 | private 30 | 31 | def build_invoice 32 | @invoice = Invoice.new( 33 | entity: @entity, 34 | emission_date: @params[:created_at], 35 | authorization_code: nil, 36 | receipt: bill, 37 | bill_type_id: @params[:bill_type_id], 38 | logo_url: @entity.logo.to_s, 39 | note: @params[:note], 40 | cbu: @params[:cbu], 41 | alias: @params[:alias], 42 | ) 43 | end 44 | 45 | def build_items 46 | return unless @params[:items] 47 | 48 | @params[:items].each { |item| build_item(item) } 49 | end 50 | 51 | def build_item(item) 52 | @invoice.items.build( 53 | code: item[:code], 54 | description: item[:description], 55 | unit_price: item[:unit_price], 56 | quantity: item[:quantity] || 1, 57 | bonus_percentage: item[:bonus_percentage] || 0, 58 | metric_unit: item[:metric_unit] || Invoice::Creator::DEFAULT_ITEM_UNIT, 59 | iva_aliquot_id: item[:iva_aliquot_id], 60 | ) 61 | end 62 | 63 | def build_associated_invoices 64 | return unless @params[:associated_invoices] 65 | 66 | @params[:associated_invoices].each do |item| 67 | @invoice.associated_invoices.build( 68 | invoice: @invoice, 69 | bill_type_id: item[:bill_type_id], 70 | emission_date: item[:date], 71 | receipt: "#{item[:sale_point_id]}-#{item[:number]}", 72 | ) 73 | end 74 | end 75 | 76 | def bill 77 | "#{format('%0004d', @params[:sale_point_id])}-#{format('%008d', @params[:bill_number])}" 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/rails' 2 | require 'should_not/rspec' 3 | require 'shoulda/matchers' 4 | require 'webmock/rspec' 5 | require 'simplecov' 6 | require 'simplecov-lcov' 7 | 8 | SimpleCov::Formatter::LcovFormatter.config do |c| 9 | c.report_with_single_file = true 10 | c.single_report_path = 'coverage/lcov.info' 11 | end 12 | 13 | SimpleCov.start 'rails' do 14 | add_filter '/app/uploaders/' 15 | add_filter '/bin/' 16 | add_filter '/db/' 17 | add_filter '/spec/' 18 | 19 | SimpleCov.formatter = 20 | SimpleCov::Formatter::MultiFormatter.new \ 21 | [SimpleCov::Formatter::HTMLFormatter, 22 | SimpleCov::Formatter::LcovFormatter] 23 | end 24 | 25 | RSpec.configure do |config| 26 | config.use_transactional_fixtures = true 27 | config.infer_spec_type_from_file_location! 28 | config.filter_rails_from_backtrace! 29 | 30 | config.expect_with :rspec do |expectations| 31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 32 | end 33 | 34 | config.mock_with :rspec do |mocks| 35 | mocks.verify_partial_doubles = true 36 | end 37 | 38 | config.filter_run :focus 39 | config.run_all_when_everything_filtered = true 40 | config.shared_context_metadata_behavior = :apply_to_host_groups 41 | 42 | config.include FactoryBot::Syntax::Methods 43 | 44 | Shoulda::Matchers.configure do |config| 45 | config.integrate do |with| 46 | with.test_framework :rspec 47 | with.library :rails 48 | end 49 | end 50 | end 51 | 52 | RSpec::Matchers.define_negated_matcher :not_change, :change 53 | 54 | RSpec::Matchers.define :match_valid_format do |format| 55 | match do |hash| 56 | expect(hash.size).to eq(format.size) 57 | 58 | hash.each do |key, value| 59 | klasses = Array(format[key]) 60 | expect(format).to include(key) 61 | expect(klasses.any? { |klass| value.is_a?(klass) }).to be(true) 62 | end 63 | end 64 | end 65 | 66 | RSpec::Matchers.define :match_valid_representer_format do |format| 67 | match do |object| 68 | format.each do |key, value| 69 | klasses = Array(value) 70 | expect(object).to respond_to(key) 71 | expect(klasses.any? { |klass| object.send(key).is_a?(klass) }).to be(true) 72 | end 73 | end 74 | end 75 | 76 | FactoryBot::SyntaxRunner.class_eval do 77 | include RSpec::Mocks::ExampleMethods 78 | end 79 | -------------------------------------------------------------------------------- /spec/support/services/invoice/query_builder_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative './generator_support' 4 | require_relative './validator_support' 5 | 6 | class Invoice 7 | class QueryBuilderSupport 8 | RESPONSE_MASTER_KEY = 'FeCAEReq' 9 | RESPONSE_HEADER_KEY = 'FeCabReq' 10 | RESPONSE_BODY_KEY = 'FeDetReq' 11 | RESPONSE_BODY_CONTENT_KEY = 'FECAEDetRequest' 12 | 13 | RESPONSE_FORMAT = { 14 | RESPONSE_BODY_KEY => Hash, 15 | RESPONSE_HEADER_KEY => Hash, 16 | }.freeze 17 | 18 | RESPONSE_HEADER_FORMAT = { 19 | 'CantReg' => Integer, 20 | 'CbteTipo' => String, 21 | 'PtoVta' => String, 22 | }.freeze 23 | 24 | RESPONSE_BODY_FORMAT = { 25 | RESPONSE_BODY_CONTENT_KEY => Hash, 26 | }.freeze 27 | 28 | IVA_CONTENT_KEY = 'AlicIva' 29 | IVA_KEY = 'Iva' 30 | TAXES_KEY = 'Tributos' 31 | TAXES_CONTENT_KEY = 'Tributo' 32 | OPT_KEY = 'Opcionales' 33 | OPT_CONTENT_KEY = 'Opcional' 34 | 35 | RESPONSE_BODY_CONTENT_FORMAT = { 36 | 'CbteDesde' => Integer, 37 | 'CbteFch' => String, 38 | 'CbteHasta' => Integer, 39 | 'Concepto' => String, 40 | 'DocNro' => String, 41 | 'DocTipo' => String, 42 | 'FchServDesde' => String, 43 | 'FchServHasta' => String, 44 | 'FchVtoPago' => String, 45 | 'ImpIVA' => Float, 46 | 'ImpNeto' => Float, 47 | 'ImpOpEx' => Float, 48 | 'ImpTotConc' => Float, 49 | 'ImpTotal' => Float, 50 | 'ImpTrib' => Float, 51 | 'MonCotiz' => Integer, 52 | 'MonId' => String, 53 | IVA_KEY => Hash, 54 | TAXES_KEY => Hash, 55 | }.freeze 56 | 57 | IVA_CONTENT_FORMAT = { 58 | 'BaseImp' => Float, 59 | 'Id' => String, 60 | 'Importe' => Float, 61 | }.freeze 62 | 63 | TAXES_CONTENT_FORMAT = { 64 | 'Alic' => Float, 65 | 'BaseImp' => Float, 66 | 'Desc' => String, 67 | 'Id' => String, 68 | 'Importe' => Float, 69 | }.freeze 70 | 71 | OPT_CONTENT_FORMAT = { 72 | 'Id' => String, 73 | 'Valor' => String, 74 | }.freeze 75 | 76 | QUERY = Invoice::GeneratorSupport::PARAMS.deep_dup.merge( 77 | bill_number: 10, 78 | cuit: Faker::Number.number(digits: 10), 79 | ).freeze 80 | 81 | ASSOCIATED_INVOICE = Invoice::ValidatorSupport::ASSOCIATED_INVOICE.dup.freeze 82 | 83 | OPT_ATTRIBUTES = Invoice::ValidatorSupport::OPT_ATTRIBUTES.dup.freeze 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /app/services/invoice/finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class Finder 5 | include ActiveModel::Model 6 | 7 | TTL = 2.minutes 8 | 9 | attr_accessor :afip, 10 | :bill_type_id, 11 | :bill_number, 12 | :items, 13 | :response, 14 | :sale_point_id 15 | 16 | def initialize(entity:, params: nil, invoice: nil) 17 | if params.present? 18 | super(params) 19 | else 20 | build_params(invoice) 21 | end 22 | 23 | @afip = Afip::InvoicesService.new(entity) 24 | end 25 | 26 | def run 27 | return Rails.cache.read(cache_key) if Rails.cache.exist?(cache_key) 28 | 29 | @response = afip.call(:fe_comp_consultar, message) 30 | 31 | return if errors? 32 | 33 | invoice = represent_invoice 34 | 35 | Rails.cache.write(cache_key, invoice, expires_in: TTL) 36 | 37 | invoice 38 | end 39 | 40 | private 41 | 42 | def build_params(invoice) 43 | @bill_type_id = invoice.bill_type_id 44 | @sale_point_id = invoice.sale_point_id 45 | @bill_number = invoice.bill_number 46 | 47 | @items = invoice.items.map do |item| 48 | { 49 | decription: item.description, 50 | quantity: item.quantity, 51 | unit_price: item.unit_price, 52 | metric_unit: item.metric_unit, 53 | total: item.total, 54 | bonus_amount: item.bonus_amount, 55 | bonus_percentage: item.bonus_percentage, 56 | } 57 | end 58 | end 59 | 60 | def message 61 | { 62 | 'FeCompConsReq' => { 63 | 'CbteTipo' => bill_type_id, 64 | 'CbteNro' => bill_number, 65 | 'PtoVta' => sale_point_id, 66 | }, 67 | } 68 | end 69 | 70 | def errors? 71 | response[:fe_comp_consultar_response][:fe_comp_consultar_result][:errors].present? 72 | end 73 | 74 | def represent_invoice 75 | data = response.dig( 76 | :fe_comp_consultar_response, 77 | :fe_comp_consultar_result, 78 | :result_get, 79 | ) 80 | 81 | InvoiceWithDetailsRepresenter.new( 82 | bill_number: bill_number, 83 | bill_type_id: bill_type_id, 84 | data: data, 85 | items: items, 86 | sale_point_id: sale_point_id, 87 | ) 88 | end 89 | 90 | def cache_key 91 | "#{afip.entity_cuit}/#{sale_point_id}/#{bill_type_id}/#{bill_number}" 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/services/afip/person_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/services/afip/person_support' 5 | 6 | describe Afip::Person do 7 | let(:entity) { create(:entity) } 8 | 9 | before do 10 | AfipMock.mock_login 11 | PeopleServiceMock.mock(:ws_wsdl) 12 | end 13 | 14 | describe '#info' do 15 | shared_examples 'person details response' do 16 | it 'returns person details' do 17 | response = subject.info 18 | 19 | expect(response) 20 | .to match_valid_representer_format(Afip::PersonSupport::RESPONSE_FORMAT) 21 | end 22 | end 23 | 24 | let(:params) { { cuit: '20201797064' } } 25 | 26 | subject { described_class.new(params, entity) } 27 | 28 | context 'when parameters are correct for a responsible person' do 29 | before do 30 | PeopleServiceMock.mock(:natural_responsible_person) 31 | end 32 | 33 | it_behaves_like 'person details response' 34 | 35 | it 'sets person category' do 36 | expect(subject.info.category).to eq('Responsable inscripto') 37 | end 38 | end 39 | 40 | context 'when parameters are correct for a taxpayer person' do 41 | before do 42 | PeopleServiceMock.mock(:natural_single_taxpayer_person) 43 | end 44 | 45 | it_behaves_like 'person details response' 46 | 47 | it 'sets person category' do 48 | expect(subject.info.category).to eq('Monotributista') 49 | end 50 | end 51 | 52 | context 'when parameters are correct for a legal person' do 53 | before do 54 | PeopleServiceMock.mock(:legal_person) 55 | end 56 | 57 | it_behaves_like 'person details response' 58 | 59 | context 'when a response with error is returned from the external service' do 60 | let!(:people_mock) do 61 | PeopleServiceMock.mock(:person_with_invalid_address) 62 | end 63 | 64 | it 'raises an error' do 65 | expected_message = Hash.from_xml(people_mock.response.body).dig( 66 | 'Envelope', 67 | 'Body', 68 | 'getPersonaResponse', 69 | 'personaReturn', 70 | 'errorConstancia', 71 | 'error', 72 | ) 73 | 74 | expect { subject.info } 75 | .to raise_error(Afip::ResponseError) 76 | .with_message("Error de conexión con AFIP: #{expected_message}.") 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | Todo los cambios notables relacionados al proyecto serán documentados en este archivo. 3 | 4 | El formato está basado el la guía de [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | y seguimos [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.1] - 2023-01-10 8 | ### Added 9 | - Agregado de licencia MIT 10 | ### Security 11 | - Actualización de Rails de 6.1.5.1 a 6.1.7 12 | - Actualización de varias dependencias con los últimos parches de seguridad 13 | 14 | ## [1.0.7] - 2022-05-06 15 | ### Security 16 | - Actualización de Ruby de 2.7.5 a 2.7.6 17 | - Actualización de Rails de 6.1.4.7 a 6.1.5.1 18 | - Actualización de Puma por vulnerabilidad de seguridad 19 | 20 | ## [1.0.6] - 2022-03-15 21 | ### Security 22 | - Actualización de Rails de 6.1.4.4 a 6.1.4.7 23 | - Actualización de varias dependencias con los últimos parches de seguridad 24 | 25 | ## [1.0.5] - 2022-02-03 26 | ### Fixed 27 | - Corrección de log de warning de obtención de categoría de persona de AFIP al intentar representarla en formato JSON 28 | 29 | ## [1.0.4] - 2022-01-25 30 | ### Fixed 31 | - Corrección de generación de previsualizaciones de comprobantes cuando sus items no tienen unidades 32 | 33 | ### Security 34 | - Actualización de Ruby de 2.7.4 a 2.7.5 35 | - Actualización de Rails de 6.1.4.1 a 6.1.4.4 36 | 37 | ## [1.0.3] - 2021-11-18 38 | ### Security 39 | - Actualización de Puma por vulnerabilidad de seguridad 40 | 41 | ## [1.0.2] - 2021-09-30 42 | ### Added 43 | - Corrección de generación de PDF para notas de crédito 44 | - Agregado de información sobre caché en entorno desarrollo en README 45 | - Agregado de tests para diferentes tipos de comprobantes en generación de PDF 46 | 47 | ## [1.0.1] - 2021-09-17 48 | ### Added 49 | - Traducción al español del código de conducta 50 | - Agregado de templates de issues para GitHub 51 | - Agregado de badges de estado de RSpec, Rubocop y coverage en README 52 | - Agregado de sección de contribución en README 53 | 54 | ## [1.0.0] - 2021-09-10 55 | ### Added 56 | - Obtener y crear comprobantes en AFIP 57 | - Exportar y previsualizar comprobante en formato PDF 58 | - Administrar entidades para representar personas jurídicas o físicas de AFIP 59 | - Consultar datos con CUIT de persona física o jurídica en AFIP 60 | - Documentación inicial: README, código de conducta, guía de contribución y colección de Postman 61 | - Configuración de Capistrano para deploy 62 | - Configuración de Docker 63 | - Configuración de GitHub Actions 64 | -------------------------------------------------------------------------------- /app/services/invoice/creator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class Creator 5 | DEFAULT_ITEM_UNIT = 'unidades' 6 | 7 | attr_reader :invoice 8 | 9 | def initialize(entity:, params: nil, cae: nil, bill_number: nil) 10 | @entity = entity 11 | @params = params 12 | @cae = cae 13 | @bill_number = bill_number 14 | end 15 | 16 | def call 17 | ActiveRecord::Base.transaction do 18 | @invoice = Invoice.create!(invoice_params) 19 | 20 | RecipientLoader.new(@invoice).call!(@params[:recipient_number]) 21 | 22 | create_items 23 | create_associated_invoices 24 | end 25 | @invoice 26 | end 27 | 28 | private 29 | 30 | def create_items 31 | return unless @params[:items] 32 | 33 | @params[:items].each do |item| 34 | InvoiceItem.create!(items_params(item)) 35 | end 36 | end 37 | 38 | def create_associated_invoices 39 | return unless @params[:associated_invoices] 40 | 41 | @params[:associated_invoices].each do |item| 42 | AssociatedInvoice.create!(associated_invoice_params(item)) 43 | end 44 | end 45 | 46 | def invoice_params 47 | { 48 | entity: @entity, 49 | emission_date: @params[:created_at], 50 | authorization_code: @cae, 51 | receipt: bill(@params[:sale_point_id], @bill_number), 52 | bill_type_id: @params[:bill_type_id], 53 | logo_url: @entity.logo.to_s, 54 | note: @params[:note], 55 | cbu: @params[:cbu], 56 | alias: @params[:alias], 57 | } 58 | end 59 | 60 | def items_params(item) 61 | { 62 | invoice: @invoice, 63 | code: item[:code], 64 | description: item[:description], 65 | unit_price: item[:unit_price], 66 | quantity: item[:quantity] || 1, 67 | bonus_percentage: item[:bonus_percentage] || 0, 68 | metric_unit: item[:metric_unit] || DEFAULT_ITEM_UNIT, 69 | iva_aliquot_id: item[:iva_aliquot_id], 70 | } 71 | end 72 | 73 | def associated_invoice_params(item) 74 | { 75 | invoice: @invoice, 76 | bill_type_id: item[:bill_type_id], 77 | emission_date: item[:date], 78 | receipt: "#{item[:sale_point_id]}-#{item[:number]}", 79 | } 80 | end 81 | 82 | def bill(sale_point_id, bill_number) 83 | "#{format('%0004d', sale_point_id)}-#{format('%008d', bill_number)}" 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded on 7 | # every request. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | config.read_encrypted_secrets = true 15 | 16 | # Show full error reports. 17 | config.consider_all_requests_local = true 18 | 19 | # Enable/disable caching. By default caching is disabled. 20 | if ENV['REDIS_URL'].present? 21 | config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] } 22 | elsif Rails.root.join('tmp/caching-dev.txt').exist? 23 | config.action_controller.perform_caching = true 24 | 25 | config.cache_store = :file_store, Rails.root.join('tmp', 'cache') 26 | config.public_file_server.headers = { 27 | 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}", 28 | } 29 | else 30 | config.action_controller.perform_caching = false 31 | 32 | config.cache_store = :null_store 33 | end 34 | 35 | # Use the lowest log level to ensure availability of diagnostic information 36 | # when problems arise. 37 | config.log_level = :debug 38 | 39 | if ENV['RAILS_LOG_TO_STDOUT'].present? 40 | logger = ActiveSupport::Logger.new($stdout) 41 | logger.formatter = config.log_formatter 42 | config.logger = ActiveSupport::TaggedLogging.new(logger) 43 | end 44 | 45 | # Don't care if the mailer can't send. 46 | config.action_mailer.raise_delivery_errors = false 47 | 48 | config.action_mailer.perform_caching = false 49 | 50 | # Print deprecation notices to the Rails logger. 51 | config.active_support.deprecation = :log 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Raises error for missing translations 57 | # config.action_view.raise_on_missing_translations = true 58 | 59 | # Use an evented file watcher to asynchronously detect changes in source code, 60 | # routes, locales, etc. This feature depends on the listen gem. 61 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 62 | 63 | Rails.application.routes.default_url_options[:host] = 'http://localhost:3001' 64 | end 65 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Puma can serve each request in a thread from an internal thread pool. 4 | # The `threads` method setting takes two numbers: a minimum and maximum. 5 | # Any libraries that use thread pools should be configured to match 6 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 7 | # and maximum; this matches the default thread size of Active Record. 8 | # 9 | threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) 10 | threads threads_count, threads_count 11 | 12 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 13 | # 14 | port ENV.fetch('PORT', 3000) 15 | 16 | # Specifies the `environment` that Puma will run in. 17 | # 18 | environment ENV.fetch('RAILS_ENV', 'development') 19 | 20 | # Specifies the number of `workers` to boot in clustered mode. 21 | # Workers are forked webserver processes. If using threads and workers together 22 | # the concurrency of the application would be max `threads` * `workers`. 23 | # Workers do not work on JRuby or Windows (both of which do not support 24 | # processes). 25 | # 26 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 27 | 28 | # Use the `preload_app!` method when specifying a `workers` number. 29 | # This directive tells Puma to first boot the application and load code 30 | # before forking the application. This takes advantage of Copy On Write 31 | # process behavior so workers use less memory. If you use this option 32 | # you need to make sure to reconnect any threads in the `on_worker_boot` 33 | # block. 34 | # 35 | # preload_app! 36 | 37 | # If you are preloading your application and using Active Record, it's 38 | # recommended that you close any connections to the database before workers 39 | # are forked to prevent connection leakage. 40 | # 41 | # before_fork do 42 | # ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord) 43 | # end 44 | 45 | # The code in the `on_worker_boot` will be called if you are using 46 | # clustered mode by specifying a number of `workers`. After each worker 47 | # process is booted, this block will be run. If you are using the `preload_app!` 48 | # option, you will want to use this block to reconnect to any threads 49 | # or connections that may have been created at application boot, as Ruby 50 | # cannot share connections between processes. 51 | # 52 | # on_worker_boot do 53 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord) 54 | # end 55 | # 56 | 57 | # Allow puma to be restarted by `rails restart` command. 58 | plugin :tmp_restart 59 | -------------------------------------------------------------------------------- /spec/support/services/invoice/finder_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class FinderSupport 5 | RESPONSE = { 6 | concept_type_id: '3', 7 | recipient_type_id: '80', 8 | recipient_number: '20308769608', 9 | bill_number: '0001-00000012', 10 | bill_type_id: '1', 11 | total_amount: '3170', 12 | untaxed_amount: '200', 13 | net_amount: '2000', 14 | exempt_amount: '300', 15 | tax_amount: '250', 16 | iva_amount: '420', 17 | service_from: '01/03/2018', 18 | service_to: '31/03/2018', 19 | due_date: '20/04/2018', 20 | currency_id: 'PES', 21 | quotation: '1', 22 | authorization_code: '68166645934991', 23 | emission_type: 'CAE', 24 | expiracy_date: '30/04/2018', 25 | created_at: '20/04/2018 16:55:58', 26 | sale_point_id: 3, 27 | iva: [ 28 | { id:5, net_amount:2000.0, total_amount:420.0 }, 29 | ], 30 | taxes: [ 31 | { id:1, description:'-', rate:2.0, net_amount:150.0, total_amount:150.0 }, 32 | { id:2, description:'-', rate:2.0, net_amount:100.0, total_amount:100.0 }, 33 | ], 34 | items: [], 35 | }.freeze 36 | 37 | AFIP_SERVICE_INVOICE_FORMAT = { 38 | authorization_code: String, 39 | bill_number: String, 40 | bill_type_id: String, 41 | concept_type_id: String, 42 | created_at: String, 43 | currency_id: String, 44 | due_date: String, 45 | emission_type: String, 46 | exempt_amount: String, 47 | expiracy_date: String, 48 | items: Array, 49 | iva: Array, 50 | iva_amount: String, 51 | net_amount: String, 52 | quotation: String, 53 | recipient_number: String, 54 | recipient_type_id: String, 55 | sale_point_id: String, 56 | service_from: String, 57 | service_to: String, 58 | tax_amount: String, 59 | taxes: Array, 60 | total_amount: String, 61 | untaxed_amount: String, 62 | }.freeze 63 | 64 | AFIP_PRODUCT_INVOICE_FORMAT = AFIP_SERVICE_INVOICE_FORMAT.merge( 65 | due_date: NilClass, 66 | service_from: NilClass, 67 | service_to: NilClass, 68 | ).freeze 69 | 70 | AFIP_IVA_FORMAT = { 71 | id: Integer, 72 | net_amount: Float, 73 | total_amount: Float, 74 | }.freeze 75 | 76 | AFIP_TAX_FORMAT = { 77 | description: String, 78 | id: Integer, 79 | net_amount: Float, 80 | rate: Float, 81 | total_amount: Float, 82 | }.freeze 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /config/deploy/production.rb: -------------------------------------------------------------------------------- 1 | # server-based syntax 2 | # ====================== 3 | # Defines a single server with a list of roles and multiple properties. 4 | # You can define all roles on a single server, or split them: 5 | 6 | # server "example.com", user: "deploy", roles: %w{app db web}, my_property: :my_value 7 | # server "example.com", user: "deploy", roles: %w{app web}, other_property: :other_value 8 | # server "db.example.com", user: "deploy", roles: %w{db} 9 | server ENV['PRODUCTION_SERVER_SSH_NAME'], 10 | port: 22, 11 | roles: [:web, :app, :db] 12 | 13 | # role-based syntax 14 | # ================== 15 | 16 | # Defines a role with one or multiple servers. The primary server in each 17 | # group is considered to be the first unless any hosts have the primary 18 | # property set. Specify the username and a domain or IP for the server. 19 | # Don't use `:all`, it's a meta role. 20 | 21 | # role :app, %w{deploy@example.com}, my_property: :my_value 22 | # role :web, %w{user1@primary.com user2@additional.com}, other_property: :other_value 23 | # role :db, %w{deploy@example.com} 24 | 25 | # Configuration 26 | # ============= 27 | set :rails_env, 'production' 28 | set :stage, :production 29 | 30 | if ENV['BRANCH'] 31 | set :branch, ENV['BRANCH'] if ENV['BRANCH'] 32 | else 33 | set :branch, 'main' 34 | end 35 | 36 | # You can set any configuration variable like in config/deploy.rb 37 | # These variables are then only loaded and set in this stage. 38 | # For available Capistrano configuration variables see the documentation page. 39 | # http://capistranorb.com/documentation/getting-started/configuration/ 40 | # Feel free to add new variables to customise your setup. 41 | 42 | 43 | 44 | # Custom SSH Options 45 | # ================== 46 | # You may pass any option but keep in mind that net/ssh understands a 47 | # limited set of options, consult the Net::SSH documentation. 48 | # http://net-ssh.github.io/net-ssh/classes/Net/SSH.html#method-c-start 49 | # 50 | # Global options 51 | # -------------- 52 | # set :ssh_options, { 53 | # keys: %w(/home/rlisowski/.ssh/id_rsa), 54 | # forward_agent: false, 55 | # auth_methods: %w(password) 56 | # } 57 | # 58 | # The server-based syntax can be used to override options: 59 | # ------------------------------------ 60 | # server "example.com", 61 | # user: "user_name", 62 | # roles: %w{web app}, 63 | # ssh_options: { 64 | # user: "user_name", # overrides user setting above 65 | # keys: %w(/home/user_name/.ssh/id_rsa), 66 | # forward_agent: false, 67 | # auth_methods: %w(publickey password) 68 | # # password: "please use keys" 69 | # } 70 | -------------------------------------------------------------------------------- /spec/services/invoice/builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/services/invoice/builder_support' 5 | 6 | describe Invoice::Builder do 7 | let(:entity) { create(:entity) } 8 | let(:params) { Invoice::BuilderSupport::PARAMS } 9 | 10 | before do 11 | AfipMock.mock_login 12 | PeopleServiceMock.mock(:ws_wsdl) 13 | end 14 | 15 | subject { described_class.new(params, entity) } 16 | 17 | describe '#call' do 18 | let!(:people_mock) do 19 | PeopleServiceMock.mock(:natural_responsible_person) 20 | end 21 | 22 | it 'returns a non-persisted invoice instance' do 23 | invoice = subject.call 24 | 25 | expect(invoice).to be_an_instance_of(Invoice) 26 | expect(invoice).not_to be_persisted 27 | end 28 | 29 | it 'builds invoice items' do 30 | invoice = subject.call 31 | 32 | expect(invoice.items).not_to be_empty 33 | expect(invoice.items.size).to eq(params[:items].size) 34 | 35 | params[:items].each do |item_data| 36 | item = invoice.items.find do |record| 37 | record.description == item_data[:description] 38 | end 39 | 40 | expect(item).to have_attributes(item_data) 41 | end 42 | end 43 | 44 | it 'does not create any invoice' do 45 | expect { subject.call }.not_to change { Invoice.count } 46 | end 47 | 48 | it 'fetches person information from external service' do 49 | subject.call 50 | 51 | expect(people_mock).to have_been_requested 52 | end 53 | 54 | context 'when invoice has associated invoices' do 55 | let(:params) do 56 | Invoice::BuilderSupport::PARAMS.merge( 57 | associated_invoices: [Invoice::BuilderSupport::ASSOCIATED_INVOICE], 58 | ) 59 | end 60 | 61 | it 'builds associated invoices' do 62 | invoice = subject.call 63 | 64 | expect(invoice.associated_invoices).not_to be_empty 65 | 66 | expect(invoice.associated_invoices.size) 67 | .to eq(params[:associated_invoices].size) 68 | end 69 | end 70 | 71 | context 'when no metric unit is provided for each item' do 72 | before do 73 | params[:items].each do |item| 74 | item[:metric_unit] = nil 75 | end 76 | end 77 | 78 | it 'builds invoice items with default unit' do 79 | invoice = subject.call 80 | 81 | expect(invoice.items).not_to be_empty 82 | expect(invoice.items.size).to eq(params[:items].size) 83 | 84 | invoice.items.each do |item| 85 | expect(item[:metric_unit]).not_to be_blank 86 | expect(item[:metric_unit]).to eq(Invoice::Creator::DEFAULT_ITEM_UNIT) 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/routing/static_routing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe 'routes for Static' do 4 | describe 'without version' do 5 | it 'routes GET /bill_types to bill_types' do 6 | expect(get('/bill_types')).to route_to('v1/static#bill_types') 7 | end 8 | 9 | it 'routes GET /concept_types to concept_types' do 10 | expect(get('/concept_types')).to route_to('v1/static#concept_types') 11 | end 12 | 13 | it 'routes GET /currencies to currencies' do 14 | expect(get('/currencies')).to route_to('v1/static#currencies') 15 | end 16 | 17 | it 'routes GET /document_types to document_types' do 18 | expect(get('/document_types')).to route_to('v1/static#document_types') 19 | end 20 | 21 | it 'routes GET /iva_types to iva_types' do 22 | expect(get('/iva_types')).to route_to('v1/static#iva_types') 23 | end 24 | 25 | it 'routes GET /sale_points to sale_points' do 26 | expect(get('/sale_points')).to route_to('v1/static#sale_points') 27 | end 28 | 29 | it 'routes GET /tax_types to tax_types' do 30 | expect(get('/tax_types')).to route_to('v1/static#tax_types') 31 | end 32 | 33 | it 'routes GET /optionals to optionals' do 34 | expect(get('/optionals')).to route_to('v1/static#optionals') 35 | end 36 | 37 | it 'routes GET /dummy to dummy' do 38 | expect(get('/dummy')).to route_to('v1/static#is_working') 39 | end 40 | end 41 | 42 | describe 'v1' do 43 | it 'routes GET /v1/bill_types to bill_types' do 44 | expect(get('/v1/bill_types')).to route_to('v1/static#bill_types') 45 | end 46 | 47 | it 'routes GET /v1/concept_types to concept_types' do 48 | expect(get('/v1/concept_types')).to route_to('v1/static#concept_types') 49 | end 50 | 51 | it 'routes GET /v1/currencies to currencies' do 52 | expect(get('/v1/currencies')).to route_to('v1/static#currencies') 53 | end 54 | 55 | it 'routes GET /v1/document_types to document_types' do 56 | expect(get('/v1/document_types')).to route_to('v1/static#document_types') 57 | end 58 | 59 | it 'routes GET /v1/iva_types to iva_types' do 60 | expect(get('/v1/iva_types')).to route_to('v1/static#iva_types') 61 | end 62 | 63 | it 'routes GET /v1/sale_points to sale_points' do 64 | expect(get('/v1/sale_points')).to route_to('v1/static#sale_points') 65 | end 66 | 67 | it 'routes GET /v1/tax_types to tax_types' do 68 | expect(get('/v1/tax_types')).to route_to('v1/static#tax_types') 69 | end 70 | 71 | it 'routes GET /v1/optionals to optionals' do 72 | expect(get('/v1/optionals')).to route_to('v1/static#optionals') 73 | end 74 | 75 | it 'routes GET /v1/dummy to dummy' do 76 | expect(get('/v1/dummy')).to route_to('v1/static#is_working') 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/models/invoice_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/models/invoice_support' 5 | 6 | describe Invoice, type: :model do 7 | describe 'Basics' do 8 | it 'has a valid factory' do 9 | expect(build(:invoice)).to be_valid 10 | end 11 | end 12 | 13 | describe 'Associations' do 14 | it { is_expected.to belong_to(:entity) } 15 | it { is_expected.to have_many(:associated_invoices).dependent(:destroy) } 16 | it { is_expected.to have_many(:items).class_name('InvoiceItem').dependent(:destroy) } 17 | end 18 | 19 | describe 'Validations' do 20 | it { is_expected.to validate_presence_of(:authorization_code) } 21 | it { is_expected.to validate_presence_of(:bill_type_id) } 22 | it { is_expected.to validate_presence_of(:emission_date) } 23 | it { is_expected.to validate_presence_of(:receipt) } 24 | end 25 | 26 | describe '#qr_code' do 27 | let!(:invoice_mock) { InvoicesServiceMock.mock(:invoice) } 28 | 29 | before do 30 | AfipMock.mock_login 31 | InvoicesServiceMock.mock(:ws_wsdl) 32 | end 33 | 34 | subject { create(:invoice) } 35 | 36 | it 'fetches information from external service' do 37 | subject.qr_code 38 | 39 | expect(invoice_mock).to have_been_requested 40 | end 41 | 42 | it 'returns QR information in JSON format' do 43 | expect(JSON.parse(subject.qr_code).symbolize_keys) 44 | .to match_valid_format(InvoiceSupport::QR_FORMAT) 45 | end 46 | end 47 | 48 | describe '#fce?' do 49 | context 'when bill_type_id is an electronic credit invoice id' do 50 | %w[201 206 211].each do |bill_type_id| 51 | subject { build(:invoice, bill_type_id: bill_type_id).fce? } 52 | 53 | it { is_expected.to be true } 54 | end 55 | end 56 | 57 | context 'when bill_type_id is not an electronic credit invoice id' do 58 | subject { build(:invoice, bill_type_id: 1).fce? } 59 | 60 | it { is_expected.to be false } 61 | end 62 | end 63 | 64 | describe '#note?' do 65 | context 'when bill_type_id is a note id' do 66 | %w[2 3 7 8 12 13 52 53].each do |bill_type_id| 67 | subject { build(:invoice, bill_type_id: bill_type_id).note? } 68 | 69 | it { is_expected.to be true } 70 | end 71 | end 72 | 73 | context 'when bill_type_id is an electronic credit note id' do 74 | %w[202 203 207 208 212 213].each do |bill_type_id| 75 | subject { build(:invoice, bill_type_id: bill_type_id).note? } 76 | 77 | it { is_expected.to be true } 78 | end 79 | end 80 | 81 | context 'when bill_type_id is neither a note id or an electronic credit note id' do 82 | subject { build(:invoice, bill_type_id: 1).note? } 83 | 84 | it { is_expected.to be false } 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/support/responses/product_invoice_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Homologacion - efa 6 | 2018-11-26T12:55:29.863155-03:00 7 | 2.12.5.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 1 15 | 80 16 | 20379452257 17 | 1 18 | 1 19 | 20181127 20 | 12899.55 21 | 2000 22 | 5650 23 | 3000 24 | 1000.8 25 | 1248.75 26 | 27 | 28 | 29 | PES 30 | 1 31 | 32 | 33 | 6 34 | 35 | 6000 36 | 16.68 37 | 1000.8 38 | 39 | 40 | 41 | 42 | 4 43 | 950 44 | 99.75 45 | 46 | 47 | 5 48 | 2000 49 | 420 50 | 51 | 52 | 6 53 | 2700 54 | 729 55 | 56 | 57 | A 58 | 68486700848308 59 | CAE 60 | 20181207 61 | 20181126124836 62 | 63 | 64 | 10063 65 | Factura (CbteDesde igual a CbteHasta), DocTipo, DocNro, no se encuentra inscripto en condicion ACTIVA en el impuesto (IVA). 66 | 67 | 68 | 1 69 | 4 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /spec/support/responses/wsaa_wsdl.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/support/responses/natural_single_taxpayer_person_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ARGÜELLO 7 | 8 | 1900 9 | BUENOS AIRES 10 | 47 1421 11 | 1 12 | LA PLATA NOROESTE CALLE 50 13 | FISCAL 14 | 15 | ACTIVO 16 | 20221124643 17 | 12 18 | ALFREDO OSCAR 19 | CUIT 20 | FISICA 21 | 22 | 23 | 24 | F LOCACIONES DE SERVICIO 25 | 40 26 | 20 27 | 201701 28 | 29 | 30 | MONOTRIBUTO 31 | 20 32 | 201106 33 | 34 | 35 | 36 | 37 | CULTIVO DE VID PARA VINIFICAR 38 | 12110 39 | 883 40 | 2 41 | 201311 42 | 43 | 44 | ELABORACIÓN DE VINOS 45 | 110212 46 | 883 47 | 3 48 | 201707 49 | 50 | 51 | SERVICIOS DE PRÁCTICAS DE DIAGNÓSTICO POR IMÁGENES 52 | 863120 53 | 883 54 | 1 55 | 201402 56 | 57 | 58 | REGIMENES DE INFORMACIÓN 59 | 103 60 | 201112 61 | 62 | 63 | APORTES SEG.SOCIAL AUTONOMOS 64 | 308 65 | 201201 66 | 67 | 68 | 69 | 2018-10-03T12:21:03.098-03:00 70 | awshomo.afip.gov.ar 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /spec/support/responses/legal_person_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 1900 8 | BUENOS AIRES 9 | 47 1421 10 | 1 11 | LA PLATA NOROESTE CALLE 50 12 | FISCAL 13 | 14 | ACTIVO 15 | 1986-06-18T12:00:00-03:00 16 | 30611224849 17 | 12 18 | GALTEC SA 19 | CUIT 20 | JURIDICA 21 | 22 | 23 | 24 | CONSTRUCCIÓN, REFORMA Y REPARACIÓN DE EDIFICIOS NO RESIDENCIALES 25 | 410021 26 | 883 27 | 1 28 | 201311 29 | 30 | 31 | GANANCIAS SOCIEDADES 32 | 10 33 | 198608 34 | 35 | 36 | IVA 37 | 30 38 | 199002 39 | 40 | 41 | REGIMENES DE INFORMACIÓN 42 | 103 43 | 200701 44 | 45 | 46 | BP-ACCIONES O PARTICIPACIONES 47 | 211 48 | 200305 49 | 50 | 51 | SICORE-IMPTO.A LAS GANANCIAS 52 | 217 53 | 200001 54 | 55 | 56 | EMPLEADOR-APORTES SEG. SOCIAL 57 | 301 58 | 198701 59 | 60 | 61 | PROFESIONES LIBERALES, SINDICOS Y DIRECTOR DE SOCIEDAD ANONIMA. 62 | 217 63 | 116 64 | 200001 65 | RETENCION 66 | 67 | 68 | 69 | 2018-09-28T17:52:59.719-03:00 70 | aws.afip.gov.ar 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /app/representers/invoice_with_details_representer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InvoiceWithDetailsRepresenter < OpenStruct 4 | include Representable::JSON 5 | 6 | DATETIME_FORMAT = '%Y%m%d%H%M%S' 7 | 8 | property :authorization_code 9 | property :bill_number 10 | property :bill_type_id 11 | property :concept_type_id 12 | property :created_at 13 | property :currency_id 14 | property :due_date 15 | property :emission_type 16 | property :exempt_amount 17 | property :expiracy_date 18 | property :items 19 | property :iva 20 | property :iva_amount 21 | property :net_amount 22 | property :quotation 23 | property :recipient_number 24 | property :recipient_type_id 25 | property :sale_point_id 26 | property :service_from 27 | property :service_to 28 | property :tax_amount 29 | property :taxes 30 | property :total_amount 31 | property :untaxed_amount 32 | 33 | # rubocop:disable Metrics/MethodLength 34 | # rubocop:disable Metrics/AbcSize 35 | def initialize(bill_number:, bill_type_id:, data:, items:, sale_point_id:) 36 | super() 37 | 38 | self.authorization_code = data[:cod_autorizacion] 39 | self.bill_number = bill_number.to_s 40 | self.bill_type_id = bill_type_id.to_s 41 | self.concept_type_id = data[:concepto] 42 | self.created_at = format_datetime(data[:fch_proceso]) 43 | self.currency_id = data[:mon_id] 44 | self.due_date = format_date(data[:fch_vto_pago]) 45 | self.emission_type = data[:emision_tipo] 46 | self.exempt_amount = data[:imp_op_ex] 47 | self.expiracy_date = format_date(data[:fch_vto]) 48 | self.items = items || [] 49 | self.iva = format_iva(data[:iva]) 50 | self.iva_amount = data[:imp_iva] 51 | self.net_amount = data[:imp_neto] 52 | self.quotation = data[:mon_cotiz] 53 | self.recipient_number = data[:doc_nro] 54 | self.recipient_type_id = data[:doc_tipo] 55 | self.sale_point_id = sale_point_id 56 | self.service_from = format_date(data[:fch_serv_desde]) 57 | self.service_to = format_date(data[:fch_serv_hasta]) 58 | self.tax_amount = data[:imp_trib] 59 | self.taxes = format_taxes(data[:tributos]) 60 | self.total_amount = data[:imp_total] 61 | self.untaxed_amount = data[:imp_tot_conc] 62 | end 63 | # rubocop:enable Metrics/MethodLength 64 | # rubocop:enable Metrics/AbcSize 65 | 66 | private 67 | 68 | def format_date(date) 69 | return if date.blank? 70 | 71 | Date 72 | .parse(date, Invoice::Schema::DATE_FORMAT) 73 | .strftime('%d/%m/%Y') 74 | end 75 | 76 | def format_datetime(datetime) 77 | DateTime 78 | .parse(datetime, DATETIME_FORMAT) 79 | .strftime('%d/%m/%Y %H:%M:%S') 80 | end 81 | 82 | def format_iva(iva) 83 | return unless iva && iva[:alic_iva] 84 | 85 | Array.wrap(iva[:alic_iva]).map do |item| 86 | { 87 | id: item[:id].to_i, 88 | net_amount: item[:base_imp].to_f, 89 | total_amount: item[:importe].to_f, 90 | } 91 | end 92 | end 93 | 94 | def format_taxes(taxes) 95 | return unless taxes && taxes[:tributo] 96 | 97 | Array.wrap(taxes[:tributo]).map do |item| 98 | { 99 | id: item[:id].to_i, 100 | description: item[:desc] || '-', 101 | rate: item[:alic].to_f, 102 | net_amount: item[:base_imp].to_f, 103 | total_amount: item[:importe].to_f, 104 | } 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/services/invoice/finder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/services/invoice/finder_support' 5 | 6 | describe Invoice::Finder do 7 | let(:entity) { create(:entity) } 8 | 9 | before do 10 | Rails.cache.clear 11 | 12 | AfipMock.mock_login 13 | InvoicesServiceMock.mock(:ws_wsdl) 14 | end 15 | 16 | describe '#run' do 17 | shared_examples 'invoice details response' do |response_format| 18 | it 'returns invoice details' do 19 | response = subject.run 20 | 21 | expect(response).to match_valid_representer_format(response_format) 22 | expect(response.iva).not_to be_empty 23 | expect(response.taxes).not_to be_empty 24 | 25 | response.iva.each do |item| 26 | expect(item) 27 | .to match_valid_format(Invoice::FinderSupport::AFIP_IVA_FORMAT) 28 | end 29 | 30 | response.taxes.each do |item| 31 | expect(item) 32 | .to match_valid_format(Invoice::FinderSupport::AFIP_TAX_FORMAT) 33 | end 34 | end 35 | end 36 | 37 | context 'when an internal id is received' do 38 | let(:invoice) { create(:invoice, entity: entity) } 39 | 40 | subject { described_class.new(invoice: invoice, entity: entity) } 41 | 42 | context 'and invoice is for services' do 43 | before { InvoicesServiceMock.mock(:invoice) } 44 | 45 | it_behaves_like 'invoice details response', 46 | Invoice::FinderSupport::AFIP_SERVICE_INVOICE_FORMAT 47 | end 48 | 49 | context 'and invoice is for products' do 50 | before { InvoicesServiceMock.mock(:product_invoice) } 51 | 52 | it_behaves_like 'invoice details response', 53 | Invoice::FinderSupport::AFIP_PRODUCT_INVOICE_FORMAT 54 | end 55 | end 56 | 57 | context 'when AFIP data is received' do 58 | let(:params) do 59 | { bill_number: '10', bill_type_id: '11', sale_point_id: '1' } 60 | end 61 | 62 | before { InvoicesServiceMock.mock(:invoice) } 63 | 64 | subject { described_class.new(params: params, entity: entity) } 65 | 66 | it_behaves_like 'invoice details response', 67 | Invoice::FinderSupport::AFIP_SERVICE_INVOICE_FORMAT 68 | end 69 | 70 | context 'when a previous call is requested' do 71 | let(:invoice) { create(:invoice, entity: entity) } 72 | let!(:invoice_mock) { InvoicesServiceMock.mock(:invoice) } 73 | 74 | before { described_class.new(invoice: invoice, entity: entity).run } 75 | 76 | subject { described_class.new(invoice: invoice, entity: entity) } 77 | 78 | it 'returns a cached version of the invoice' do 79 | remove_request_stub(invoice_mock) 80 | subject.run 81 | end 82 | end 83 | 84 | context 'when a response with error is returned from the external service' do 85 | let!(:invoice_mock) do 86 | InvoicesServiceMock.mock(:invoice_not_found) 87 | end 88 | 89 | let(:invoice) { create(:invoice, entity: entity) } 90 | 91 | before do 92 | described_class.new(invoice: invoice, entity: entity) 93 | end 94 | 95 | subject { described_class.new(invoice: invoice, entity: entity) } 96 | 97 | it 'returns nil' do 98 | expect(subject.run).to be_nil 99 | end 100 | 101 | it 'does not cache response' do 102 | remove_request_stub(invoice_mock) 103 | 104 | expect { subject.run } 105 | .to raise_error(WebMock::NetConnectNotAllowedError) 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/shared_examples/shared_examples_for_afip.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/rails' 2 | 3 | RSpec.shared_examples 'AFIP connection errors management' do |operation| 4 | let(:message) { { param: 'test' } } 5 | 6 | context 'when external service connection raises Savon::SOAPFault' do 7 | before do 8 | stub_savon_error( 9 | Savon::SOAPFault.new(build_http_object, nil), 10 | operation, 11 | ) 12 | end 13 | 14 | it 'raises connection error' do 15 | expect { subject.call(operation, message) }.to raise_error( 16 | Afip::InvalidRequestError, 17 | "Error de conexión con AFIP: solicitud inválida con error 'error message'.", 18 | ) 19 | end 20 | end 21 | 22 | context 'when external service connection raises Savon::HTTPError' do 23 | before do 24 | stub_savon_error( 25 | Savon::HTTPError.new(build_http_object), 26 | operation, 27 | ) 28 | end 29 | 30 | it 'raises connection error' do 31 | expect { subject.call(operation, message) }.to raise_error( 32 | Afip::UnsuccessfulResponseError, 33 | "Error de conexión con AFIP: respuesta no exitosa (HTTP 500) con error 'error message'.", 34 | ) 35 | end 36 | end 37 | 38 | context 'when external service connection raises Savon::InvalidResponseError' do 39 | before do 40 | stub_savon_error( 41 | Savon::InvalidResponseError.new(build_http_object), 42 | operation, 43 | ) 44 | end 45 | 46 | it 'raises connection error' do 47 | expect { subject.call(operation, message) }.to raise_error( 48 | Afip::InvalidResponseError, 49 | 'Error de conexión con AFIP: respuesta de servidor inválida.', 50 | ) 51 | end 52 | end 53 | 54 | context 'when external service connection raises Net::ReadTimeout' do 55 | before do 56 | stub_savon_error( 57 | Net::ReadTimeout, 58 | operation, 59 | ) 60 | end 61 | 62 | it 'raises connection error' do 63 | expect { subject.call(operation, message) }.to raise_error( 64 | Afip::TimeoutError, 65 | 'Error de conexión con AFIP: timeout de conexión con AFIP.', 66 | ) 67 | end 68 | end 69 | 70 | context 'when service raises StandardError' do 71 | before do 72 | stub_savon_error( 73 | StandardError, 74 | operation, 75 | ) 76 | end 77 | 78 | it 'raises connection error' do 79 | expect { subject.call(operation, message) }.to raise_error( 80 | Afip::UnexpectedError, 81 | 'Error de conexión con AFIP: error no esperado.', 82 | ) 83 | end 84 | end 85 | 86 | private 87 | 88 | def stub_savon_error(error, operation) 89 | allow_any_instance_of(Savon::Client) 90 | .to receive(:call) 91 | .and_call_original 92 | 93 | allow(error).to receive(:to_s).and_return('error message') 94 | 95 | allow_any_instance_of(Savon::Client) 96 | .to receive(:call) 97 | .with(operation, message: an_instance_of(Hash)) 98 | .and_raise(error) 99 | end 100 | 101 | def build_http_object 102 | http = OpenStruct.new 103 | http.code = 500 104 | 105 | http 106 | end 107 | end 108 | 109 | RSpec.shared_examples 'AFIP WS operation execution' do |operation| 110 | let(:message) { { param: 'test' } } 111 | 112 | it_behaves_like 'AFIP connection errors management', operation 113 | it_behaves_like 'AFIP connection errors management', :login_cms 114 | 115 | it 'connects with external service and fetches information' do 116 | subject.call(operation, message) 117 | 118 | afip_mocks.each do |mock| 119 | expect(mock).to have_been_requested 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/support/services/invoice/generator_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Invoice 4 | class GeneratorSupport 5 | PARAMS = { 6 | sale_point_id: '0001', 7 | concept_type_id: '2', 8 | recipient_type_id: '8', 9 | recipient_number: '0001-00000001', 10 | net_amount: 2000, 11 | iva_amount: 250, 12 | untaxed_amount: 1800, 13 | exempt_amount: 300, 14 | tax_amount: 250, 15 | iva: [ 16 | { 17 | id: 1, 18 | net_amount: 1500, 19 | total_amount: 150, 20 | }, 21 | { 22 | id: 2, 23 | net_amount: 500, 24 | total_amount: 100, 25 | }, 26 | ], 27 | taxes: [ 28 | { 29 | id: 1, 30 | description: 'Some description', 31 | net_amount: 150, 32 | rate: 2.0, 33 | total_amount: 150, 34 | }, 35 | { 36 | id: 2, 37 | description: 'Another description', 38 | net_amount: 100, 39 | rate: 2.0, 40 | total_amount: 100, 41 | }, 42 | ], 43 | bill_type_id: '11', 44 | created_at: Date.today.strftime(Invoice::Schema::DATE_FORMAT), 45 | total_amount: 4600, 46 | service_from: 1.month.ago.strftime(Invoice::Schema::DATE_FORMAT), 47 | service_to: 1.day.ago.strftime(Invoice::Schema::DATE_FORMAT), 48 | due_date: Date.today.strftime(Invoice::Schema::DATE_FORMAT), 49 | associated_invoices: [], 50 | items: [ 51 | { 52 | description: 'Servicios de Informática', 53 | quantity: 10, 54 | unit_price: 150.5, 55 | metric_unit: 'horas', 56 | iva_aliquot_id: 5, # 21% 57 | bonus_percentage: 0, 58 | }, 59 | { 60 | description: 'Servicios de hosting', 61 | quantity: 1, 62 | unit_price: 550, 63 | metric_unit: 'unidades', 64 | iva_aliquot_id: 4, # 10.5% 65 | bonus_percentage: 10, 66 | }, 67 | { 68 | description: 'No gravado', 69 | quantity: 2, 70 | unit_price: 1000, 71 | metric_unit: 'unidades', 72 | iva_aliquot_id: StaticResource::IvaTypes::UNTAXED_ID, 73 | bonus_percentage: 10, 74 | }, 75 | { 76 | description: 'Exento', 77 | quantity: 1, 78 | unit_price: 300, 79 | metric_unit: 'unidades', 80 | iva_aliquot_id: StaticResource::IvaTypes::EXEMPT_ID, 81 | bonus_percentage: 0, 82 | }, 83 | ], 84 | }.freeze 85 | 86 | CREATED_INVOICE_FORMAT = { 87 | bill: String, 88 | bill_number: String, 89 | cae: String, 90 | cae_expiracy: String, 91 | internal_id: Integer, 92 | render_url: String, 93 | sale_point_id: String, 94 | token: String, 95 | }.freeze 96 | 97 | DEFAULT_INVOICE_FORMAT = { 98 | bill: NilClass, 99 | bill_number: NilClass, 100 | cae: NilClass, 101 | cae_expiracy: NilClass, 102 | internal_id: NilClass, 103 | render_url: NilClass, 104 | sale_point_id: NilClass, 105 | token: NilClass, 106 | }.freeze 107 | 108 | def self.next_bill_number 109 | data = Hash.from_xml( 110 | InvoicesServiceMock.mock(:last_bill_number).response.body, 111 | ).dig( 112 | 'Envelope', 113 | 'Body', 114 | 'FECompUltimoAutorizadoResponse', 115 | 'FECompUltimoAutorizadoResult', 116 | ) 117 | 118 | sale_point = data['PtoVta'].to_i 119 | number = data['CbteNro'].to_i + 1 120 | 121 | "#{format('%0004d', sale_point)}-#{format('%008d', number)}" 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/mocks/invoices_service_mock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InvoicesServiceMock < AfipMock 4 | ENDPOINT = %r{/wsfev1/service.asmx}i 5 | 6 | def ws_wsdl 7 | WebMock 8 | .stub_request(:get, /#{ENDPOINT}\?wsdl$/i) 9 | .to_return(body: File.read('spec/support/responses/wsfe_wsdl.xml')) 10 | end 11 | 12 | def bill_types 13 | stub_action( 14 | soap_action: :fe_param_get_tipos_cbte, 15 | response_body: File.read('spec/support/responses/bill_types_response.xml'), 16 | ) 17 | end 18 | 19 | def concept_types 20 | stub_action( 21 | soap_action: :fe_param_get_tipos_concepto, 22 | response_body: File.read('spec/support/responses/concept_types_response.xml'), 23 | ) 24 | end 25 | 26 | def currencies 27 | stub_action( 28 | soap_action: :fe_param_get_tipos_monedas, 29 | response_body: File.read('spec/support/responses/currencies_response.xml'), 30 | ) 31 | end 32 | 33 | def document_types 34 | stub_action( 35 | soap_action: :fe_param_get_tipos_doc, 36 | response_body: File.read('spec/support/responses/document_types_response.xml'), 37 | ) 38 | end 39 | 40 | def iva_types 41 | stub_action( 42 | soap_action: :fe_param_get_tipos_iva, 43 | response_body: File.read('spec/support/responses/iva_types_response.xml'), 44 | ) 45 | end 46 | 47 | def sale_points 48 | stub_action( 49 | soap_action: :fe_param_get_ptos_venta, 50 | response_body: File.read('spec/support/responses/sale_points_response.xml'), 51 | ) 52 | end 53 | 54 | def sale_points_error 55 | stub_action( 56 | soap_action: :fe_param_get_ptos_venta, 57 | response_body: File.read('spec/support/responses/sale_points_error_response.xml'), 58 | ) 59 | end 60 | 61 | def optionals 62 | stub_action( 63 | soap_action: :fe_param_get_tipos_opcional, 64 | response_body: File.read('spec/support/responses/optionals_response.xml'), 65 | ) 66 | end 67 | 68 | def other_sale_points 69 | stub_action( 70 | soap_action: :fe_param_get_ptos_venta, 71 | response_body: File.read('spec/support/responses/other_sale_points_response.xml'), 72 | ) 73 | end 74 | 75 | def tax_types 76 | stub_action( 77 | soap_action: :fe_param_get_tipos_tributos, 78 | response_body: File.read('spec/support/responses/tax_types_response.xml'), 79 | ) 80 | end 81 | 82 | def invoice 83 | stub_action( 84 | soap_action: :fe_comp_consultar, 85 | response_body: File.read('spec/support/responses/invoice_response.xml'), 86 | ) 87 | end 88 | 89 | def product_invoice 90 | stub_action( 91 | soap_action: :fe_comp_consultar, 92 | response_body: File.read('spec/support/responses/product_invoice_response.xml'), 93 | ) 94 | end 95 | 96 | def invoice_not_found 97 | stub_action( 98 | soap_action: :fe_comp_consultar, 99 | response_body: File.read('spec/support/responses/invoice_not_found_response.xml'), 100 | ) 101 | end 102 | 103 | def last_bill_number 104 | stub_action( 105 | soap_action: :fe_comp_ultimo_autorizado, 106 | response_body: File.read('spec/support/responses/last_bill_number_response.xml'), 107 | ) 108 | end 109 | 110 | def create_invoice 111 | stub_action( 112 | soap_action: :fecae_solicitar, 113 | response_body: File.read('spec/support/responses/create_invoice_response.xml'), 114 | ) 115 | end 116 | 117 | def create_invoice_error 118 | stub_action( 119 | soap_action: :fecae_solicitar, 120 | response_body: File.read('spec/support/responses/create_invoice_error_response.xml'), 121 | ) 122 | end 123 | 124 | private 125 | 126 | def endpoint 127 | ENDPOINT 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/services/invoice/recipient_loader_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'support/services/invoice/recipient_loader_support' 5 | 6 | describe Invoice::RecipientLoader do 7 | describe '#call' do 8 | let!(:invoice) { create(:invoice) } 9 | 10 | before do 11 | AfipMock.mock_login 12 | PeopleServiceMock.mock(:ws_wsdl) 13 | InvoicesServiceMock.mock(:ws_wsdl) 14 | end 15 | 16 | subject { described_class.new(invoice) } 17 | 18 | context 'when person is loaded from external service' do 19 | shared_examples "invoice's recipient loader" do 20 | let!(:people_mock) do 21 | PeopleServiceMock.mock(:natural_responsible_person) 22 | end 23 | 24 | it 'loads recipient into invoice' do 25 | expect { subject.call(cuit) } 26 | .to change { invoice.recipient } 27 | .from(nil) 28 | 29 | expect(invoice.recipient) 30 | .to match_valid_format(Invoice::RecipientLoaderSupport::RECIPIENT_FORMAT) 31 | 32 | expect(invoice.recipient.symbolize_keys).to eq({ 33 | address: 'SAN MARTIN 8', 34 | category: 'Responsable inscripto', 35 | city: 'ALTO DE LA LOMA', 36 | full_address: 'SAN MARTIN 8 ALTO DE LA LOMA, JUJUY ', 37 | name: 'MARTINCHUS RAZONAR', 38 | state: 'JUJUY', 39 | zipcode: '4500', 40 | }) 41 | end 42 | 43 | it 'fetches recipient information from external service' do 44 | subject.call(cuit) 45 | 46 | expect(people_mock).to have_been_requested 47 | end 48 | end 49 | 50 | context 'when CUIT is received' do 51 | let(:cuit) { Faker::Number.number(digits: 10) } 52 | 53 | it_behaves_like "invoice's recipient loader" 54 | end 55 | 56 | context 'when CUIT is not received' do 57 | let(:cuit) { nil } 58 | let!(:invoice_mock) { InvoicesServiceMock.mock(:invoice) } 59 | 60 | context 'and invoice can be fetched from external service' do 61 | it_behaves_like "invoice's recipient loader" 62 | 63 | it 'fetches invoice information from external service' do 64 | PeopleServiceMock.mock(:natural_responsible_person) 65 | 66 | subject.call 67 | 68 | expect(invoice_mock).to have_been_requested 69 | end 70 | end 71 | 72 | context 'and invoice cannot be fetched from external service' do 73 | before do 74 | InvoicesServiceMock.mock(:invoice_not_found) 75 | end 76 | 77 | it 'does not load recipient into invoice' do 78 | expect { subject.call } 79 | .not_to change { invoice.recipient } 80 | .from(nil) 81 | end 82 | 83 | context 'and invoice already has a recipient' do 84 | before do 85 | invoice.update(recipient: { anything: 123 }) 86 | end 87 | 88 | it "does not change existing invoice's recipient" do 89 | expect { subject.call } 90 | .not_to change { invoice.recipient } 91 | .from({ 'anything' => 123 }) 92 | end 93 | end 94 | end 95 | end 96 | end 97 | 98 | context 'when an error is raised while fetching person from AFIP' do 99 | before do 100 | InvoicesServiceMock.mock(:invoice) 101 | PeopleServiceMock.mock(:natural_responsible_person) 102 | 103 | expect_any_instance_of(Afip::Person) 104 | .to receive(:info) 105 | .and_raise(StandardError) 106 | end 107 | 108 | it 'returns false' do 109 | expect(subject.call).to be false 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Attempt to read encrypted secrets from `config/secrets.yml.enc`. 20 | # Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or 21 | # `config/secrets.yml.key`. 22 | config.read_encrypted_secrets = true 23 | 24 | # Disable serving static files from the `/public` folder by default since 25 | # Apache or NGINX already handles this. 26 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 27 | 28 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 29 | # config.action_controller.asset_host = 'http://assets.example.com' 30 | 31 | # Specifies the header that your server uses for sending files. 32 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 33 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 34 | 35 | # Mount Action Cable outside main process or domain 36 | # config.action_cable.mount_path = nil 37 | # config.action_cable.url = 'wss://example.com/cable' 38 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 39 | 40 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 41 | # config.force_ssl = true 42 | 43 | # Use the lowest log level to ensure availability of diagnostic information 44 | # when problems arise. 45 | config.log_level = :debug 46 | 47 | # Prepend all log lines with the following tags. 48 | config.log_tags = [:request_id] 49 | 50 | # Use a different cache store in production. 51 | # config.cache_store = :mem_cache_store 52 | 53 | # Use a real queuing backend for Active Job (and separate queues per environment) 54 | # config.active_job.queue_adapter = :resque 55 | # config.active_job.queue_name_prefix = "afip-invoices_#{Rails.env}" 56 | config.action_mailer.perform_caching = false 57 | 58 | # Ignore bad email addresses and do not raise email delivery errors. 59 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 60 | # config.action_mailer.raise_delivery_errors = false 61 | 62 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 63 | # the I18n.default_locale when a translation cannot be found). 64 | config.i18n.fallbacks = true 65 | 66 | # Send deprecation notices to registered listeners. 67 | config.active_support.deprecation = :notify 68 | 69 | # Use default logging formatter so that PID and timestamp are not suppressed. 70 | config.log_formatter = ::Logger::Formatter.new 71 | 72 | # Use a different logger for distributed setups. 73 | # require 'syslog/logger' 74 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 75 | 76 | if ENV['RAILS_LOG_TO_STDOUT'].present? 77 | logger = ActiveSupport::Logger.new($stdout) 78 | logger.formatter = config.log_formatter 79 | config.logger = ActiveSupport::TaggedLogging.new(logger) 80 | end 81 | 82 | # Do not dump schema after migrations. 83 | config.active_record.dump_schema_after_migration = false 84 | Rails.application.routes.default_url_options[:host] = ENV['HOST'] 85 | end 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Guía de contribución 2 | 3 | Antes que nada, ¡muchas gracias por ayudarnos a mejorar el proyecto! 4 | 5 | Antes de comenzar a colaborar te pedimos que leas algunos documentos: 6 | 7 | * [Guía de contribución](CONTRIBUTING.md) 8 | * [Código de conducta](CODE_OF_CONDUCT.md) 9 | * [Guía de estilos de Rubocup](https://github.com/github/rubocop-github/blob/master/STYLEGUIDE.md) 10 | 11 | ### ¿Cómo nos podés ayudar? 12 | 13 | * [Reporte de un problema de seguridad](#reporte-de-un-problema-de-seguridad) 14 | * [Reporte de un bug](#reporte-de-un-bug) 15 | * [Sugerencias de mejoras](#sugerencias-de-mejoras) 16 | * [Revisión de pull requests](#revision-de-pull-requests) 17 | * [Mejora de la documentación](#mejora-de-la-documentación) 18 | 19 | 20 | ## Reporte de un problema de seguridad 21 | 22 | Si encontraste un problema de seguridad, por favor, te pedimos que **no lo reportes como un issue** en el repositorio. Lo podés hacer a través de nuestra cuenta de email: info@unagi.com.ar. 23 | 24 | ## Reporte de un Bug 25 | 26 | **Podés revisar si ya alguien lo reporto** en los [issues](https://github.com/unagisoftware/afip-invoices/issues). Si no encontraste el bug en los issues, podés [abrir uno nuevo](https://github.com/unagisoftware/afip-invoices/issues/new). Es necesario incluir un título y una descripción clara del bug, con toda la información relevante y, si es posible, con un ejemplo, y cuál debería ser el comportamiento esperado. 27 | 28 | ## Sugerencias de mejoras 29 | 30 | Las mejoras se reportan como [issues](https://github.com/unagisoftware/afip-invoices/issues) en nuestro repositorio, y deben contemplar lo siguiente: 31 | 32 | * Usar un **título y descripción** que sean claros para describir la mejora propuesta. 33 | 34 | * **Describir los pasos** con el mayor detalle posible necesarios para la mejora. 35 | 36 | * **Incluir ejemplos** para describir los pasos, incluyendo snippets de código usando [bloques de código](https://help.github.com/articles/markdown-basics/#multiple-lines). 37 | 38 | * **Describir el comportamiento actual y cuál sería el esperado aplicando la mejora**. 39 | 40 | * **Explicar por qué considerás que esta mejora podría ser útil** para la mayoría que usa el proyecto. 41 | 42 | ## Revisión de pull requests 43 | 44 | Es **importante** que primero revises tu PR vos mismo. Asegurate de que: 45 | 46 | - [ ] Los cambios cumplan con lo descrito en el issue. 47 | - [ ] Revisar que no falle ningún test. 48 | - [ ] Revisar el code coverage de los tests. 49 | - [ ] Revisar aspectos técnicos. 50 | - [ ] Revisar que el código se adecue a la [guía de estilos de Rubocup](https://github.com/github/rubocop-github/blob/master/STYLEGUIDE.md). 51 | - [ ] Usar los labels correspondientes: bug, enhancement (mejora) o documentation. 52 | - [ ] Si agregás un nuevo endpoint o modificás los parámetros de entrada y/o de salida de un endpoint, recordá modificar la colección de Postman. 53 | 54 | ## Mejora de la documentación 55 | 56 | Si creés que falta algo en la documentación o que se puede mejorar, podés [abrir un nuevo issue](https://github.com/unagisoftware/afip-invoices/issues/new) con el label **documentation** modificando algunos de los documentos que tenemos en el repositorio: 57 | 58 | - `README.md`. 59 | - `api_afip.postman_collection.json`. 60 | - `CONTRIBUING.md`. 61 | 62 | O simplemente reportando que pensás que se puede mejorar o si es necesario mejorar algo en la [wiki](https://github.com/unagisoftware/afip-invoices/wiki). 63 | 64 | ## Mensajes en los commits de Git 65 | 66 | * **Usar tiempo presente** ("Add feature" NO "Added feature"). 67 | * **Usar modo imperativo** ("Move cursor to" NO "Moves cursor to"). 68 | * **Limitar la primera línea a 60 caracteres** o menos. 69 | * **Referenciar al issue** al final de la primer línea. 70 | * **Separar sujeto y cuerpo** del mensaje con una línea en blanco. 71 | * Usar el cuerpo del mensaje para **explicar el qué y el porqué en vez del cómo**. 72 | --------------------------------------------------------------------------------