diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/Gemfile.lock b/Gemfile.lock index 3368298..3dc5e28 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,24 +2,13 @@ PATH remote: . specs: norma43_parser (4.0.0) - virtus (~> 1.0) zeitwerk (~> 2.0) GEM remote: https://rubygems.org/ specs: ast (2.4.2) - axiom-types (0.1.1) - descendants_tracker (~> 0.0.4) - ice_nine (~> 0.11.0) - thread_safe (~> 0.3, >= 0.3.1) - coercible (1.0.0) - descendants_tracker (~> 0.0.1) - descendants_tracker (0.0.4) - thread_safe (~> 0.3, >= 0.3.1) diff-lcs (1.5.1) - equalizer (0.0.11) - ice_nine (0.11.2) json (2.7.2) json (2.7.2-java) language_server-protocol (3.17.0.3) @@ -66,14 +55,7 @@ GEM ruby-progressbar (1.13.0) strscan (3.1.0) strscan (3.1.0-java) - thread_safe (0.3.6) - thread_safe (0.3.6-java) unicode-display_width (2.5.0) - virtus (1.0.5) - axiom-types (~> 0.1) - coercible (~> 1.0) - descendants_tracker (~> 0.0, >= 0.0.3) - equalizer (~> 0.0, >= 0.0.9) zeitwerk (2.6.15) PLATFORMS diff --git a/lib/norma43/models.rb b/lib/norma43/models.rb index 9a361ad..d05f23e 100644 --- a/lib/norma43/models.rb +++ b/lib/norma43/models.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "virtus" - module Norma43 module Models DEBIT_CODE = 1 diff --git a/lib/norma43/models/account.rb b/lib/norma43/models/account.rb index 91a2a69..4654f1d 100644 --- a/lib/norma43/models/account.rb +++ b/lib/norma43/models/account.rb @@ -1,30 +1,62 @@ # frozen_string_literal: true -require "virtus" - module Norma43 module Models class Account - include Virtus.model + include Mixins::AttributesAssignment + + attr_accessor :bank_code, + :branch_code, + :account_number, + :start_date, + :end_date, + :balance_code, + :balance_amount, + :currency_code, + :information_mode_code, + :abbreviated_name, + :debit_entries, + :debit_amount, + :credit_entries, + :credit_amount, + :transactions - attribute :bank_code - attribute :branch_code - attribute :account_number - attribute :start_date - attribute :end_date - attribute :balance_code - attribute :balance_amount - attribute :currency_code - attribute :information_mode_code - attribute :abbreviated_name - attribute :debit_entries - attribute :debit_amount - attribute :credit_entries - attribute :credit_amount - attribute :transactions, Array[Transaction] + def initialize(attributes = EMPTY_ATTRIBUTES) + @bank_code, + @branch_code, + @account_number, + @start_date, + @end_date, + @balance_code, + @balance_amount, + @currency_code, + @information_mode_code, + @abbreviated_name, + @debit_entries, + @debit_amount, + @credit_entries, + @credit_amount, + transactions = Hash(attributes).values_at( + :bank_code, + :branch_code, + :account_number, + :start_date, + :end_date, + :balance_code, + :balance_amount, + :currency_code, + :information_mode_code, + :abbreviated_name, + :debit_entries, + :debit_amount, + :credit_entries, + :credit_amount, + :transactions) + @transactions = Array(transactions).map { |attrs| Transaction.new(attrs) } + end def iban - @iban ||= SpanishIban.from_account(self) + SpanishIban.from_account(self) end end end diff --git a/lib/norma43/models/additional_currency.rb b/lib/norma43/models/additional_currency.rb index e071374..c7e495b 100644 --- a/lib/norma43/models/additional_currency.rb +++ b/lib/norma43/models/additional_currency.rb @@ -1,16 +1,22 @@ # frozen_string_literal: true -require "virtus" - module Norma43 module Models class AdditionalCurrency - include Virtus.model + include Mixins::AttributesAssignment + + attr_accessor :data_code, :currency_code, :amount, :free - attribute :data_code - attribute :currency_code - attribute :amount - attribute :free + def initialize(attributes = EMPTY_ATTRIBUTES) + @data_code, + @currency_code, + @amount, + @free = Hash(attributes).values_at( + :data_code, + :currency_code, + :amount, + :free) + end end end end diff --git a/lib/norma43/models/additional_item.rb b/lib/norma43/models/additional_item.rb index 1b6b0a3..abf0c94 100644 --- a/lib/norma43/models/additional_item.rb +++ b/lib/norma43/models/additional_item.rb @@ -1,15 +1,20 @@ # frozen_string_literal: true -require "virtus" - module Norma43 module Models class AdditionalItem - include Virtus.model + include Mixins::AttributesAssignment + + attr_accessor :data_code, :item_1, :item_2 - attribute :data_code - attribute :item_1 - attribute :item_2 + def initialize(attributes = EMPTY_ATTRIBUTES) + @data_code, + @item_1, + @item_2 = Hash(attributes).values_at( + :data_code, + :item_1, + :item_2) + end end end end diff --git a/lib/norma43/models/document.rb b/lib/norma43/models/document.rb index a6b5065..5068998 100644 --- a/lib/norma43/models/document.rb +++ b/lib/norma43/models/document.rb @@ -1,22 +1,41 @@ # frozen_string_literal: true -require "virtus" - module Norma43 module Models class Document - include Virtus.model + include Mixins::AttributesAssignment + + attr_accessor :id, :created_at, :delivery_number, :file_type, :name, :number_of_lines, :accounts - attribute :id - attribute :created_at - attribute :delivery_number - attribute :file_type - attribute :name - attribute :number_of_lines - attribute :accounts, Array[Account] + def initialize(attributes = EMPTY_ATTRIBUTES) + @id, + @created_at, + @delivery_number, + @file_type, + @name, + @number_of_lines, + accounts = Hash(attributes).values_at( + :id, + :created_at, + :delivery_number, + :file_type, + :name, + :number_of_lines, + :accounts) + @accounts = Array(accounts).map { |account| Account.new(account) } + end + # @deprecated Please ask each transaction inside accounts for their transaction_date instead def transaction_date - accounts.map(&:date).compact.first + warn "[DEPRECATION] `transaction_date` is deprecated, use `#transaction_date` from transactions in `#accounts` instead" + date = nil + + accounts.flat_map(&:transactions).each { |transaction| + date = transaction&.transaction_date + break unless date.nil? + } + + date end end end diff --git a/lib/norma43/models/mixins/attributes_assignment.rb b/lib/norma43/models/mixins/attributes_assignment.rb new file mode 100644 index 0000000..25a748e --- /dev/null +++ b/lib/norma43/models/mixins/attributes_assignment.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Norma43 + module Models + module Mixins + module AttributesAssignment + EMPTY_ATTRIBUTES = {}.freeze + + def attributes=(new_attributes) + Hash(new_attributes).each do |attr_name, attr_value| + attr_writer_method_name = "#{attr_name}=".to_sym + next unless public_methods(false).include?(attr_writer_method_name) + + public_send(attr_writer_method_name, attr_value) + end + end + + def attributes + instance_variables.map { |ivar_name| + attr_reader_method_name = ivar_name.to_s.delete_prefix("@").to_sym + next unless public_methods(false).include?(attr_reader_method_name) + + attr_value = public_send(attr_reader_method_name) + + [attr_reader_method_name, attr_value] + }.compact.to_h + end + alias_method :to_hash, :attributes # Implicit coercion for `Hash(model)` + alias_method :to_h, :attributes # Explicit coercion + end + end + end +end diff --git a/lib/norma43/models/transaction.rb b/lib/norma43/models/transaction.rb index 1f52245..5853ecc 100644 --- a/lib/norma43/models/transaction.rb +++ b/lib/norma43/models/transaction.rb @@ -1,24 +1,50 @@ # frozen_string_literal: true -require "virtus" - module Norma43 module Models class Transaction - include Virtus.model + include Mixins::AttributesAssignment + + attr_accessor :origin_branch_code, + :transaction_date, + :value_date, + :shared_item, + :own_item, + :amount_code, + :amount, + :document_number, + :reference_1, + :reference_2, + :additional_items, + :additional_currency + + def initialize(attributes = EMPTY_ATTRIBUTES) + @origin_branch_code, + @transaction_date, + @value_date, + @shared_item, + @own_item, + @amount_code, + @amount, + @document_number, + @reference_1, + @reference_2, + additional_items, + additional_currency = Hash(attributes).values_at( + :origin_branch_code, + :transaction_date, + :value_date, + :shared_item, + :own_item, + :amount_code, + :amount, + :document_number, + :reference_1, + :reference_2) + @additional_items = Array(additional_items).map { |attrs| AdditionalItem.new(attrs) } + @additional_currency = AdditionalCurrency.new(additional_currency) if additional_currency + end - attribute :origin_branch_code - attribute :transaction_date - attribute :value_date - attribute :shared_item - attribute :own_item - attribute :amount_code - attribute :amount - attribute :document_number - attribute :reference_1 - attribute :reference_2 - attribute :additional_items, Array[AdditionalItem] - attribute :additional_currency, AdditionalCurrency def debit?; self.amount_code == DEBIT_CODE end end end diff --git a/norma43_parser.gemspec b/norma43_parser.gemspec index 054df4a..3e54d13 100644 --- a/norma43_parser.gemspec +++ b/norma43_parser.gemspec @@ -19,7 +19,6 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] - spec.add_runtime_dependency "virtus", "~> 1.0" spec.add_runtime_dependency "zeitwerk", "~> 2.0" spec.add_development_dependency "rake", "~> 13.0" diff --git a/spec/norma43/line_processors/account_end_spec.rb b/spec/norma43/line_processors/account_end_spec.rb index 692eba9..9fd81ad 100644 --- a/spec/norma43/line_processors/account_end_spec.rb +++ b/spec/norma43/line_processors/account_end_spec.rb @@ -3,10 +3,7 @@ module Norma43 module LineProcessors RSpec.describe AccountEnd do - let :line do - double "Line", attributes: {} - end - + let(:line) { instance_double(LineParsers::AccountEnd, attributes: {}) } let(:account) { Norma43::Models::Account.new } let(:contexts) { Norma43::Utils::Contexts.new( [ diff --git a/spec/norma43/line_processors/account_start_spec.rb b/spec/norma43/line_processors/account_start_spec.rb index 7a6799a..6741526 100644 --- a/spec/norma43/line_processors/account_start_spec.rb +++ b/spec/norma43/line_processors/account_start_spec.rb @@ -2,8 +2,8 @@ module Norma43 module LineProcessors - RSpec.describe "AccountStart" do - let(:line) { double "Line", attributes: {} } + RSpec.describe AccountStart do + let(:line) { instance_double(LineParsers::AccountStart, attributes: {}) } let(:document) { Norma43::Models::Document.new } let(:contexts) { Norma43::Utils::Contexts.new } @@ -20,7 +20,7 @@ module LineProcessors end context "when AccountStart is called" do - let(:fake_account) { double "Norma43::Models::Account" } + let(:fake_account) { instance_double(Norma43::Models::Account) } before do allow(Norma43::Models::Account).to receive(:new) { fake_account } end diff --git a/spec/norma43/line_processors/additional_currency_spec.rb b/spec/norma43/line_processors/additional_currency_spec.rb index d9907fd..ec94038 100644 --- a/spec/norma43/line_processors/additional_currency_spec.rb +++ b/spec/norma43/line_processors/additional_currency_spec.rb @@ -2,8 +2,8 @@ module Norma43 module LineProcessors - RSpec.describe "AdditionalCurrency" do - let(:line) { double "Line", attributes: {} } + RSpec.describe AdditionalCurrency do + let(:line) { instance_double(LineParsers::AdditionalCurrency, attributes: {}) } let(:transaction) { Norma43::Models::Transaction.new } let(:contexts) { Norma43::Utils::Contexts.new( [ @@ -22,7 +22,7 @@ module LineProcessors end context "when AdditionalCurrency is called" do - let(:fake_additional_currency) { double "Models::AdditionalCurrency" } + let(:fake_additional_currency) { instance_double(Models::AdditionalCurrency) } before do allow(Models::AdditionalCurrency).to receive(:new) { fake_additional_currency } end diff --git a/spec/norma43/line_processors/additional_items_spec.rb b/spec/norma43/line_processors/additional_item_spec.rb similarity index 84% rename from spec/norma43/line_processors/additional_items_spec.rb rename to spec/norma43/line_processors/additional_item_spec.rb index 70a9472..680c40e 100644 --- a/spec/norma43/line_processors/additional_items_spec.rb +++ b/spec/norma43/line_processors/additional_item_spec.rb @@ -2,8 +2,8 @@ module Norma43 module LineProcessors - RSpec.describe "AdditionalItems" do - let(:line) { double "Line", attributes: {} } + RSpec.describe AdditionalItem do + let(:line) { instance_double(LineParsers::AdditionalItem, attributes: {}) } let(:transaction) { Norma43::Models::Transaction.new } let(:contexts) { Norma43::Utils::Contexts.new( [ @@ -22,7 +22,7 @@ module LineProcessors end context "when AdditionalItem is called" do - let(:fake_additional_item) { double "Models::AdditionalItem" } + let(:fake_additional_item) { instance_double(Models::AdditionalItem) } before do allow(Models::AdditionalItem).to receive(:new) { fake_additional_item } end diff --git a/spec/norma43/line_processors/document_end_spec.rb b/spec/norma43/line_processors/document_end_spec.rb index 43b5e7f..942bc63 100644 --- a/spec/norma43/line_processors/document_end_spec.rb +++ b/spec/norma43/line_processors/document_end_spec.rb @@ -2,15 +2,12 @@ module Norma43 module LineProcessors - RSpec.describe "DocumentEnd" do - class Thing - end - - let(:line) { double "Line", record_number: 35 } + RSpec.describe DocumentEnd do + let(:line) { instance_double(LineParsers::DocumentEnd, record_number: 35) } it "moves to the nearest document context" do document = Norma43::Models::Document.new - contexts = Norma43::Utils::Contexts.new [Thing.new, document, Thing.new, Thing.new] + contexts = Norma43::Utils::Contexts.new [anything, document, anything, anything] DocumentEnd.call line, contexts diff --git a/spec/norma43/line_processors/document_start_spec.rb b/spec/norma43/line_processors/document_start_spec.rb index 4103713..ae7ccde 100644 --- a/spec/norma43/line_processors/document_start_spec.rb +++ b/spec/norma43/line_processors/document_start_spec.rb @@ -2,8 +2,8 @@ module Norma43 module LineProcessors - RSpec.describe "DocumentStart" do - let (:line) { double "Line", attributes: {} } + RSpec.describe DocumentStart do + let(:line) { instance_double(LineParsers::DocumentStart, attributes: {}) } it "instantiates a new document with the line attributes" do allow(Models::Document).to receive :new @@ -14,7 +14,7 @@ module LineProcessors end it "sets the document as the current context" do - fake_document = double "Models::Document" + fake_document = instance_double(Models::Document) allow(Models::Document).to receive(:new) { fake_document } contexts = DocumentStart.call line, Norma43::Utils::Contexts.new diff --git a/spec/norma43/line_processors/transaction_spec.rb b/spec/norma43/line_processors/transaction_spec.rb index 9758929..f0ef3ff 100644 --- a/spec/norma43/line_processors/transaction_spec.rb +++ b/spec/norma43/line_processors/transaction_spec.rb @@ -2,8 +2,8 @@ module Norma43 module LineProcessors - RSpec.describe "Transaction" do - let(:line) { double "Line", attributes: {} } + RSpec.describe Transaction do + let(:line) { instance_double(LineParsers::Transaction, attributes: {}) } let(:account) { Norma43::Models::Account.new } let(:contexts) { Norma43::Utils::Contexts.new( [ @@ -22,7 +22,7 @@ module LineProcessors end context "when Transaction is called" do - let(:fake_transaction) { double "Models::Transaction" } + let(:fake_transaction) { instance_double(Models::Transaction) } before do allow(Models::Transaction).to receive(:new) { fake_transaction } end diff --git a/spec/norma43/models/account_spec.rb b/spec/norma43/models/account_spec.rb index fbb74d4..b70ac37 100644 --- a/spec/norma43/models/account_spec.rb +++ b/spec/norma43/models/account_spec.rb @@ -3,17 +3,51 @@ module Norma43 module Models RSpec.describe Account do + it_behaves_like "a model" + describe "#iban" do - it { is_expected.to respond_to :iban } + it { is_expected.to respond_to(:iban) } context "with the example in the documentation" do - it "delegates to SpanishIban to return a IBAN string" do + it "returns the correct IBAN string" do account = subject - allow(Account::SpanishIban).to receive(:from_account).with(account).and_return("ES0000000000000000000123") - expect(account.iban).to eq("ES0000000000000000000123") - expect(Account::SpanishIban).to have_received(:from_account).with(account) + account.account_number = 1234567 + account.bank_code = 81 + account.branch_code = 54 + + expect(account.iban).to eq("ES5400810054180001234567") end end + + context "with missing bank data" do + subject { described_class.new.iban } + it { is_expected.to be_nil } + end + + context "with missing bank code" do + subject { described_class.new(bank_code: nil, branch_code: 1234, account_number: 1234).iban } + it { is_expected.to be_nil } + end + + context "with missing branch code" do + subject { described_class.new(bank_code: 1234, branch_code: nil, account_number: 1234).iban } + it { is_expected.to be_nil } + end + + context "with missing account number" do + subject { described_class.new(bank_code: 1234, branch_code: 1234, account_number: nil).iban } + it { is_expected.to be_nil } + end + + context "with negative account number" do + subject { described_class.new(bank_code: 1234, branch_code: 1234, account_number: -1234).iban } + it { is_expected.to be_nil } + end + + context "with an account number which is too large" do + subject { described_class.new(bank_code: 1234, branch_code: 1234, account_number: 1*10**10).iban } + it { is_expected.to be_nil } + end end end end diff --git a/spec/norma43/models/document_spec.rb b/spec/norma43/models/document_spec.rb new file mode 100644 index 0000000..6757938 --- /dev/null +++ b/spec/norma43/models/document_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Norma43 + module Models + RSpec.describe Document do + it_behaves_like "a model" + + describe "#transaction_date" do + it { is_expected.to respond_to(:transaction_date) } + + context "when there are no accounts" do + subject(:document) { described_class.new({ accounts: [] }) } + + it { expect(document.transaction_date).to be_nil } + end + + context "when there are accounts" do + subject(:document) { described_class.new({ accounts: [account] }) } + + let(:account) { + Account.new(transactions: [ + nil, + Transaction.new(transaction_date: nil), + Transaction.new(transaction_date: Date.parse("2024-01-23")), + ]) + } + + it "returns the date of the first account with a transaction date" do + expect(document.transaction_date).to eq(Date.parse("2024-01-23")) + end + end + end + end + end +end diff --git a/spec/norma43/models/transaction_spec.rb b/spec/norma43/models/transaction_spec.rb new file mode 100644 index 0000000..dc360e2 --- /dev/null +++ b/spec/norma43/models/transaction_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Norma43 + module Models + RSpec.describe Transaction do + it_behaves_like "a model" + + describe "#debit?" do + it { is_expected.to respond_to(:debit?) } + + context "when amount code is code for debits" do + subject(:transaction) { described_class.new(amount_code: 1) } + + it { expect(transaction.debit?).to be_truthy } + end + + context "when amount code is code for credits" do + subject(:transaction) { described_class.new(amount_code: 2) } + + it { expect(transaction.debit?).to be_falsey } + end + end + end + end +end diff --git a/spec/norma43_spec.rb b/spec/norma43_spec.rb index 724071d..3207546 100644 --- a/spec/norma43_spec.rb +++ b/spec/norma43_spec.rb @@ -6,7 +6,7 @@ describe "#parse" do it "returns the parser results" do text = "some total-in text" - parser = double "Parser", result: "result" + parser = instance_double(Norma43::Parser, result: "result") expect(Norma43::Parser).to receive(:new).with(text) { parser } expect(Norma43.parse(text)).to eq "result" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index cbd8655..29f6ad3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "norma43" + RSpec.configure do |config| config.order = :random Kernel.srand config.seed @@ -11,6 +13,7 @@ config.mock_with :rspec do |mocks| mocks.syntax = :expect mocks.verify_partial_doubles = true + mocks.verify_doubled_constant_names = true end end diff --git a/spec/support/shared_examples_for_models.rb b/spec/support/shared_examples_for_models.rb new file mode 100644 index 0000000..50d43e3 --- /dev/null +++ b/spec/support/shared_examples_for_models.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a model" do + it { is_expected.to respond_to(:attributes) } + it { is_expected.to respond_to(:attributes=) } + it { is_expected.to respond_to(:to_h) } + it { is_expected.to respond_to(:to_hash) } + + describe "#new" do + subject(:model) { described_class.new(attributes) } + + context "when instantiated with an empty hash" do + let(:attributes) { {} } + + it "accepts it without failing" do + expect { model }.not_to raise_error + end + end + end + + describe "#attributes" do + subject { described_class.new.attributes } + + it { is_expected.not_to be_empty } + it { is_expected.to respond_to(:each_pair) } + it { is_expected.to respond_to(:to_h) } + it { is_expected.to respond_to(:to_hash) } + it { is_expected.to respond_to(:keys) } + + describe "#attributes.keys" do + subject { super().keys } + + it { is_expected.not_to be_empty } + it { is_expected.to all(be_a(Symbol)) } + end + end + + describe "#attributes=" do + subject { described_class.new.attributes=(attributes) } + + context "when passed an empty hash" do + let(:attributes) { {} } + + it "accepts it without failing" do + expect { subject }.not_to raise_error + end + end + + context "when passed a hash with unknown attribute names" do + let(:attributes) { { potato: nil } } + + it "accepts it without failing" do + expect { subject }.not_to raise_error + end + + it "does not set any new method" do + expect { subject.potato }.to raise_error(NoMethodError, /undefined method.+potato/i) + end + end + end +end