├── 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 |
--------------------------------------------------------------------------------