diff --git a/app/controllers/finance/bank_accounts_controller.rb b/app/controllers/finance/bank_accounts_controller.rb index 943fe400..a2aba712 100644 --- a/app/controllers/finance/bank_accounts_controller.rb +++ b/app/controllers/finance/bank_accounts_controller.rb @@ -5,6 +5,14 @@ class Finance::BankAccountsController < Finance::BaseController redirect_to finance_bank_account_transactions_url(@bank_accounts.first) if @bank_accounts.count == 1 end + def assign_unlinked_transactions + @bank_account = BankAccount.find(params[:id]) + count = @bank_account.assign_unlinked_transactions + redirect_to finance_bank_account_transactions_url(@bank_account), notice: t('finance.bank_accounts.controller.assign.notice', count: count) + rescue => error + redirect_to finance_bank_account_transactions_url(@bank_account), alert: t('errors.general_msg', msg: error.message) + end + def import @bank_account = BankAccount.find(params[:id]) import_method = @bank_account.find_import_method diff --git a/app/models/bank_account.rb b/app/models/bank_account.rb index 0351173b..baf2e9c7 100644 --- a/app/models/bank_account.rb +++ b/app/models/bank_account.rb @@ -12,4 +12,14 @@ class BankAccount < ApplicationRecord # @return [Function] Method wich can be called to import transaction from a bank or nil if unsupported def find_import_method end + + def assign_unlinked_transactions + count = 0 + bank_transactions.without_financial_link.includes(:supplier, :user).each do |t| + if t.assign_to_ordergroup || t.assign_to_invoice + count += 1 + end + end + count + end end diff --git a/app/models/bank_transaction.rb b/app/models/bank_transaction.rb index f2968817..5cb7b447 100644 --- a/app/models/bank_transaction.rb +++ b/app/models/bank_transaction.rb @@ -33,4 +33,48 @@ class BankTransaction < ApplicationRecord def image_url 'data:image/png;base64,' + Base64.encode64(self.image) end + + def assign_to_invoice + return false unless supplier + + content = text + content += "\n" + reference if reference.present? + invoices = supplier.invoices.unpaid.select {|i| content.include? i.number} + invoices_sum = invoices.map(&:amount).sum + return false if amount != -invoices_sum + + transaction do + link = FinancialLink.new + invoices.each {|i| i.update_attributes! financial_link: link, paid_on: date } + update_attribute :financial_link, link + end + + return true + end + + def assign_to_ordergroup + m = BankTransactionReference.parse(reference) + return unless m + + return false if m[:parts].values.sum != amount + group = Ordergroup.find_by_id(m[:group]) + return false unless group + usr = m[:user] ? User.find_by_id(m[:user]) : group.users.first + return false unless usr + + transaction do + note = "ID=#{id} (#{amount})" + link = FinancialLink.new + + m[:parts].each do |short, value| + ftt = FinancialTransactionType.find_by_name_short(short) + return false unless ftt + group.add_financial_transaction! value, note, usr, ftt, link if value > 0 + end + + update_attribute :financial_link, link + end + + return true + end end diff --git a/app/views/finance/bank_transactions/index.html.haml b/app/views/finance/bank_transactions/index.html.haml index 845cecd8..1d3dee1a 100644 --- a/app/views/finance/bank_transactions/index.html.haml +++ b/app/views/finance/bank_transactions/index.html.haml @@ -1,6 +1,7 @@ - title t('.title', name: @bank_account.name, balance: number_to_currency(@bank_account.balance)) - content_for :actionbar do + = link_to t('.assign_unlinked_transactions'), assign_unlinked_transactions_finance_bank_account_path(@bank_account), class: 'btn' = link_to t('.import_transaction'), import_finance_bank_account_path(@bank_account), class: 'btn btn-primary' .well.well-small diff --git a/config/locales/en.yml b/config/locales/en.yml index 75fd9886..7acc35dc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -775,6 +775,8 @@ en: without_extra_charge: 'without extra charge:' bank_accounts: controller: + assign: + notice: '%{count} transactions have been assigned' import: notice: '%{count} new transactions have been imported' index: diff --git a/config/routes.rb b/config/routes.rb index 87338884..93e88e31 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -195,6 +195,7 @@ Foodsoft::Application.routes.draw do resources :bank_accounts, only: [:index] do member do + get :assign_unlinked_transactions get :import end diff --git a/lib/bank_transaction_reference.rb b/lib/bank_transaction_reference.rb new file mode 100644 index 00000000..71470b31 --- /dev/null +++ b/lib/bank_transaction_reference.rb @@ -0,0 +1,20 @@ +class BankTransactionReference + + # parses a string from a bank transaction field + def self.parse(data) + m = /(^|\s)FS(?\d+)(\.(?\d+))?(?([A-Za-z]+\d+(\.\d+)?)+)(\s|$)/.match(data) + return unless m + + parts = {} + m[:parts].scan(/([A-Za-z]+)(\d+(\.\d+)?)/) do |category, value| + value = value.to_f + value += parts[category] if parts[category] + parts[category] = value + end + + ret = { group: m[:group], parts: parts } + ret[:user] = m[:user] if m[:user] + return ret + end + +end diff --git a/spec/factories/bank_transaction_type.rb b/spec/factories/bank_transaction_type.rb new file mode 100644 index 00000000..66da3550 --- /dev/null +++ b/spec/factories/bank_transaction_type.rb @@ -0,0 +1,15 @@ +require 'factory_bot' + +FactoryBot.define do + + factory :bank_account do + name { Faker::Bank.name } + iban { Faker::Bank.iban } + end + + factory :bank_transaction do + date { Faker::Date.backward(days: 14) } + text { Faker::Lorem.sentence } + end + +end diff --git a/spec/factories/invoice.rb b/spec/factories/invoice.rb new file mode 100644 index 00000000..d43af28a --- /dev/null +++ b/spec/factories/invoice.rb @@ -0,0 +1,15 @@ +require 'factory_bot' + +FactoryBot.define do + + factory :invoice do + supplier + number { rand(1..99999) } + amount { rand(0.1..26.0).round(2) } + + after :create do |invoice| + invoice.supplier.reload + end + end + +end diff --git a/spec/lib/bank_transaction_reference_spec.rb b/spec/lib/bank_transaction_reference_spec.rb new file mode 100644 index 00000000..6e9c8996 --- /dev/null +++ b/spec/lib/bank_transaction_reference_spec.rb @@ -0,0 +1,60 @@ +require_relative '../spec_helper' + +describe BankTransactionReference do + it 'returns nil for empty input' do + expect(BankTransactionReference.parse('')).to be nil + end + + it 'returns nil for invalid string' do + expect(BankTransactionReference.parse('invalid')).to be nil + end + + it 'returns nil for FS1A' do + expect(BankTransactionReference.parse('FS1A')).to be nil + end + + it 'returns nil for FS1.1A' do + expect(BankTransactionReference.parse('FS1.1A')).to be nil + end + + it 'returns nil for xFS1A1' do + expect(BankTransactionReference.parse('xFS1A1')).to be nil + end + + it 'returns nil for FS1A1x' do + expect(BankTransactionReference.parse('FS1A1x')).to be nil + end + + it 'returns correct value for FS1A1' do + expect(BankTransactionReference.parse('FS1A1')).to be { { group: 1, parts: { A: 1 } } } + end + + it 'returns correct value for FS1A1' do + expect(BankTransactionReference.parse('FS1.2A3')).to be { { group: 1, user: 2, parts: { A: 3 } } } + end + + it 'returns correct value for FS1A2B3' do + expect(BankTransactionReference.parse('FS1A2B3C4')).to be { { group: 1, parts: { A: 2, B: 3, C: 4 } } } + end + + it 'returns correct value for FS1A2B3A4' do + expect(BankTransactionReference.parse('FS1A2B3C4')).to be { { group: 1, parts: { A: 6, B: 3 } } } + end + + it 'returns correct value for FS1A2.34B5.67C8.90' do + expect(BankTransactionReference.parse('FS1A2B3C4')).to be { { group: 1, parts: { A: 2.34, B: 5.67, C: 8.90 } } } + end + + it 'returns correct value for FS123A456 with prefix' do + expect(BankTransactionReference.parse('x FS123A456')).to be { { group: 123, parts: { A: 456 } } } + end + + it 'returns correct value for FS234A567 with suffix' do + expect(BankTransactionReference.parse('FS234A567 x')).to be { { group: 234, parts: { A: 567 } } } + end + + it 'returns correct value for FS34.56A67.89 with prefix and suffix' do + expect(BankTransactionReference.parse('x FS34.56A67.89 x')).to be { { group: 34, user: 56, parts: { A: 67.89 } } } + end + +end diff --git a/spec/models/bank_transaction_spec.rb b/spec/models/bank_transaction_spec.rb new file mode 100644 index 00000000..21d3458f --- /dev/null +++ b/spec/models/bank_transaction_spec.rb @@ -0,0 +1,108 @@ +require_relative '../spec_helper' + +describe BankTransaction do + let(:bank_account) { create :bank_account } + let(:ordergroup) { create :ordergroup } + let(:supplier) { create :supplier, iban: Faker::Bank.iban } + let!(:user) { create :user, groups: [ordergroup] } + let!(:ftt_a) { create :financial_transaction_type, name_short: 'A' } + let!(:ftt_b) { create :financial_transaction_type, name_short: 'B' } + + describe 'supplier' do + let!(:invoice1) { create :invoice, supplier: supplier, number: '11', amount: 10 } + let!(:invoice2) { create :invoice, supplier: supplier, number: '22', amount: 20 } + let!(:invoice3) { create :invoice, supplier: supplier, number: '33', amount: 30 } + let!(:invoice4) { create :invoice, supplier: supplier, number: '44', amount: 40 } + let!(:invoice5) { create :invoice, supplier: supplier, number: '55', amount: 50 } + + let!(:bank_transaction1) { create :bank_transaction, bank_account: bank_account, iban: supplier.iban, reference: '11', amount: 10 } + let!(:bank_transaction2) { create :bank_transaction, bank_account: bank_account, iban: supplier.iban, reference: '22', amount: -20 } + let!(:bank_transaction3) { create :bank_transaction, bank_account: bank_account, iban: supplier.iban, reference: '33,44', amount: -70 } + let!(:bank_transaction4) { create :bank_transaction, bank_account: bank_account, iban: supplier.iban, text: '55', amount: -50 } + + it 'ignores invoices with invalid amount' do + expect(bank_transaction1.assign_to_invoice).to be false + end + + it 'can assign single invoice' do + expect(bank_transaction2.assign_to_invoice).to be true + invoice2.reload + expect(invoice2.paid_on).to eq bank_transaction2.date + expect(invoice2.financial_link).to eq bank_transaction2.financial_link + end + + it 'can assign multiple invoice' do + expect(bank_transaction3.assign_to_invoice).to be true + [invoice3, invoice4].each(&:reload) + expect(invoice3.paid_on).to eq bank_transaction3.date + expect(invoice4.paid_on).to eq bank_transaction3.date + expect(invoice3.financial_link).to eq bank_transaction3.financial_link + expect(invoice4.financial_link).to eq bank_transaction3.financial_link + end + + it 'can assign single invoice with number in text' do + expect(bank_transaction4.assign_to_invoice).to be true + invoice5.reload + expect(invoice5.paid_on).to eq bank_transaction4.date + expect(invoice5.financial_link).to eq bank_transaction4.financial_link + end + + end + + describe 'ordergroup' do + let!(:bank_transaction1) { create :bank_transaction, bank_account: bank_account, reference: "invalid", amount: 10 } + let!(:bank_transaction2) { create :bank_transaction, bank_account: bank_account, reference: "FS99A10", amount: 10 } + let!(:bank_transaction3) { create :bank_transaction, bank_account: bank_account, reference: "FS#{ordergroup.id}.99A10", amount: 10 } + let!(:bank_transaction4) { create :bank_transaction, bank_account: bank_account, reference: "FS#{ordergroup.id}A10", amount: 99 } + let!(:bank_transaction5) { create :bank_transaction, bank_account: bank_account, reference: "FS#{ordergroup.id}A10", amount: 10 } + let!(:bank_transaction6) { create :bank_transaction, bank_account: bank_account, reference: "FS#{ordergroup.id}A10B20", amount: 30 } + let!(:bank_transaction7) { create :bank_transaction, bank_account: bank_account, reference: "FS#{ordergroup.id}.#{user.id}A10", amount: 10 } + let!(:bank_transaction8) { create :bank_transaction, bank_account: bank_account, reference: "FS#{ordergroup.id}X10", amount: 10 } + + it 'ignores transaction with invalid reference' do + expect(bank_transaction1.assign_to_ordergroup).to be nil + end + + it 'ignores transaction with invalid ordergroup' do + expect(bank_transaction2.assign_to_ordergroup).to be false + end + + it 'ignores transaction with invalid user' do + expect(bank_transaction3.assign_to_ordergroup).to be false + end + + it 'ignores transaction with invalid sum' do + expect(bank_transaction4.assign_to_ordergroup).to be false + end + + it 'add transaction with one part' do + expect(bank_transaction5.assign_to_ordergroup).to be true + ft_a = user.ordergroup.financial_transactions.where(financial_transaction_type: ftt_a).first + expect(ft_a.amount).to eq 10 + expect(ft_a.financial_link).to eq bank_transaction5.financial_link + end + + it 'add transaction with multiple parts' do + expect(bank_transaction6.assign_to_ordergroup).to be true + ft_a = user.ordergroup.financial_transactions.where(financial_transaction_type: ftt_a).first + ft_b = user.ordergroup.financial_transactions.where(financial_transaction_type: ftt_b).first + expect(ft_a.amount).to eq 10 + expect(ft_a.financial_link).to eq bank_transaction6.financial_link + expect(ft_b.amount).to eq 20 + expect(ft_b.financial_link).to eq bank_transaction6.financial_link + end + + it 'add transaction with one part and user' do + expect(bank_transaction7.assign_to_ordergroup).to be true + ft_a = user.ordergroup.financial_transactions.where(financial_transaction_type: ftt_a).first + expect(ft_a.amount).to eq 10 + expect(ft_a.financial_link).to eq bank_transaction7.financial_link + end + + it 'ignores transaction with invalid short name' do + expect(bank_transaction8.assign_to_ordergroup).to be false + end + + end + +end