rock_books 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +5 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +200 -0
  8. data/RELEASE_NOTES.md +4 -0
  9. data/Rakefile +6 -0
  10. data/bin/console +14 -0
  11. data/bin/setup +8 -0
  12. data/exe/rock_books +5 -0
  13. data/lib/rock_books/cmd_line/command_line_interface.rb +391 -0
  14. data/lib/rock_books/cmd_line/main.rb +108 -0
  15. data/lib/rock_books/documents/book_set.rb +113 -0
  16. data/lib/rock_books/documents/chart_of_accounts.rb +113 -0
  17. data/lib/rock_books/documents/journal.rb +161 -0
  18. data/lib/rock_books/documents/journal_entry.rb +73 -0
  19. data/lib/rock_books/documents/journal_entry_builder.rb +148 -0
  20. data/lib/rock_books/errors/account_not_found_error.rb +20 -0
  21. data/lib/rock_books/errors/error.rb +10 -0
  22. data/lib/rock_books/filters/acct_amount_filters.rb +12 -0
  23. data/lib/rock_books/filters/journal_entry_filters.rb +84 -0
  24. data/lib/rock_books/helpers/book_set_loader.rb +62 -0
  25. data/lib/rock_books/helpers/parse_helper.rb +22 -0
  26. data/lib/rock_books/reports/balance_sheet.rb +60 -0
  27. data/lib/rock_books/reports/income_statement.rb +63 -0
  28. data/lib/rock_books/reports/multidoc_transaction_report.rb +66 -0
  29. data/lib/rock_books/reports/receipts_report.rb +57 -0
  30. data/lib/rock_books/reports/report_context.rb +15 -0
  31. data/lib/rock_books/reports/reporter.rb +118 -0
  32. data/lib/rock_books/reports/transaction_report.rb +103 -0
  33. data/lib/rock_books/reports/tx_by_account.rb +82 -0
  34. data/lib/rock_books/reports/tx_one_account.rb +63 -0
  35. data/lib/rock_books/types/account.rb +7 -0
  36. data/lib/rock_books/types/account_type.rb +33 -0
  37. data/lib/rock_books/types/acct_amount.rb +52 -0
  38. data/lib/rock_books/version.rb +3 -0
  39. data/lib/rock_books.rb +7 -0
  40. data/rock_books.gemspec +39 -0
  41. data/sample_data/minimal/rockbooks-inputs/2017-xyz-chart-of-accounts.rbt +62 -0
  42. data/sample_data/minimal/rockbooks-inputs/2017-xyz-checking-journal.rbt +17 -0
  43. data/sample_data/minimal/rockbooks-inputs/2017-xyz-general-journal.rbt +14 -0
  44. data/sample_data/minimal/rockbooks-inputs/2017-xyz-visa-journal.rbt +23 -0
  45. metadata +158 -0
@@ -0,0 +1,108 @@
1
+ require 'awesome_print'
2
+ require 'optparse'
3
+ require 'pry'
4
+ require 'shellwords'
5
+
6
+ require_relative '../../rock_books'
7
+ require_relative '../documents/book_set'
8
+ require_relative 'command_line_interface'
9
+
10
+ module RockBooks
11
+
12
+ class Main
13
+
14
+
15
+ def options_with_defaults
16
+ options = OpenStruct.new
17
+ options.input_dir = DEFAULT_INPUT_DIR
18
+ options.output_dir = DEFAULT_OUTPUT_DIR
19
+ options.receipt_dir = DEFAULT_RECEIPT_DIR
20
+ options.do_receipts = true
21
+ options
22
+ end
23
+
24
+
25
+ def prepend_environment_options
26
+ env_opt_string = ENV['ROCKBOOKS_OPTIONS']
27
+ if env_opt_string
28
+ args_to_prepend = Shellwords.shellsplit(env_opt_string)
29
+ ARGV.unshift(args_to_prepend).flatten!
30
+ end
31
+ end
32
+
33
+
34
+ # Parses the command line with Ruby's internal 'optparse'.
35
+ # OptionParser#parse! removes what it processes from ARGV, which simplifies our command parsing.
36
+ def parse_command_line
37
+ prepend_environment_options
38
+ options = options_with_defaults
39
+
40
+ OptionParser.new do |parser|
41
+
42
+ parser.on('-i', '--input_dir DIR',
43
+ "Input directory containing source data files, default: '#{DEFAULT_INPUT_DIR}'") do |v|
44
+ options.input_dir = File.expand_path(v)
45
+ end
46
+
47
+ parser.on('-o', '--output_dir DIR',
48
+ "Output directory to which report files will be written, default: '#{DEFAULT_OUTPUT_DIR}'") do |v|
49
+ options.output_dir = File.expand_path(v)
50
+ end
51
+
52
+ parser.on('-r', '--receipt_dir DIR',
53
+ "Directory root from which to find receipt filespecs, default: '#{DEFAULT_RECEIPT_DIR}'") do |v|
54
+ options.receipt_dir = File.expand_path(v)
55
+ end
56
+
57
+ parser.on('-s', '--shell', 'Start interactive shell') do |v|
58
+ options.interactive_mode = true
59
+ end
60
+
61
+ parser.on('-v', '--[no-]verbose', 'Verbose mode') do |v|
62
+ options.verbose_mode = v
63
+ end
64
+
65
+ parser.on('-y', '--[no-]say', 'Say error messages.') do |v|
66
+ options.say = v
67
+ end
68
+
69
+ parser.on('', '--[no-]receipts', 'Include report on existing and missing receipts.') do |v|
70
+ options.do_receipts = v
71
+ end
72
+ end.parse!
73
+
74
+ if options.verbose_mode
75
+ puts "Run Options:"
76
+ ap options.to_h
77
+ end
78
+
79
+ options
80
+ end
81
+
82
+
83
+ # Arg is a directory containing 'chart_of_accounts.rbd' and '*journal*.rbd' for input,
84
+ # and reports (*.rpt) will be output to this directory as well.
85
+ def call
86
+ begin
87
+ run_options = parse_command_line
88
+ CommandLineInterface.new(run_options).call
89
+ rescue => error
90
+ $stderr.puts \
91
+ <<~HEREDOC
92
+ #{error.backtrace.join("\n")}
93
+
94
+ #{error}
95
+ HEREDOC
96
+
97
+ if run_options.say
98
+ `say #{error}`
99
+ end
100
+
101
+ exit(-1)
102
+ binding.pry
103
+ raise error
104
+ end
105
+
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,113 @@
1
+ require 'awesome_print'
2
+
3
+ require_relative 'chart_of_accounts'
4
+ require_relative 'journal'
5
+ require_relative '../filters/journal_entry_filters' # for shell mode
6
+ require_relative '../helpers/parse_helper'
7
+ require_relative '../reports/balance_sheet'
8
+ require_relative '../reports/income_statement'
9
+ require_relative '../reports/multidoc_transaction_report'
10
+ require_relative '../reports/receipts_report'
11
+ require_relative '../reports/report_context'
12
+ require_relative '../reports/transaction_report'
13
+ require_relative '../reports/tx_by_account'
14
+ require_relative '../reports/tx_one_account'
15
+
16
+ module RockBooks
17
+
18
+ class BookSet < Struct.new(:run_options, :chart_of_accounts, :journals)
19
+
20
+ FILTERS = JournalEntryFilters
21
+
22
+
23
+ def initialize(run_options, chart_of_accounts, journals)
24
+ super
25
+ end
26
+
27
+
28
+ def report_context
29
+ @report_context ||= ReportContext.new(chart_of_accounts, journals, nil, nil, 80)
30
+ end
31
+
32
+
33
+ def all_reports(filter = nil)
34
+ context = report_context
35
+ report_hash = context.journals.each_with_object({}) do |journal, report_hash|
36
+ report_hash[journal.short_name] = TransactionReport.new(journal, context).call(filter)
37
+ end
38
+ report_hash['all_txns_by_date'] = MultidocTransactionReport.new(context).call(filter)
39
+ report_hash['all_txns_by_amount'] = MultidocTransactionReport.new(context).call(filter, :amount)
40
+ report_hash['all_txns_by_acct'] = TxByAccount.new(context).call
41
+ report_hash['balance_sheet'] = BalanceSheet.new(context).call
42
+ report_hash['income_statement'] = IncomeStatement.new(context).call
43
+
44
+ if run_options.do_receipts
45
+ report_hash['receipts'] = ReceiptsReport.new(context, *missing_and_existing_receipts).call
46
+ end
47
+
48
+ chart_of_accounts.accounts.each do |account|
49
+ key = 'acct_' + account.code
50
+ report = TxOneAccount.new(context, account.code).call
51
+ report_hash[key] = report
52
+ end
53
+ report_hash
54
+ end
55
+
56
+
57
+ def run_command(command)
58
+ command = command + ' 2>&1'
59
+ puts command
60
+ `#{command}`
61
+ end
62
+
63
+
64
+ def all_reports_to_files(directory = '.', filter = nil)
65
+ reports = all_reports(filter)
66
+ reports.each do |short_name, report_text|
67
+ report_directory = /^acct_/.match(short_name) ? File.join(directory, SINGLE_ACCT_SUBDIR) : directory
68
+ txt_filespec = File.join(report_directory, "#{short_name}.txt")
69
+ html_filespec = File.join(report_directory, "#{short_name}.html")
70
+ pdf_filespec = File.join(report_directory, "#{short_name}.pdf")
71
+ File.write(txt_filespec, report_text)
72
+ run_command("textutil -convert html -font 'Menlo Regular' #{txt_filespec} -output #{html_filespec}")
73
+ run_command("cupsfilter #{html_filespec} > #{pdf_filespec}")
74
+ puts "Created reports in txt, html, and pdf for #{"%-20s" % short_name} at #{File.dirname(txt_filespec)}.\n\n\n"
75
+ end
76
+ end
77
+
78
+
79
+ def journal_names
80
+ journals.map(&:short_name)
81
+ end
82
+ alias_method :jnames, :journal_names
83
+
84
+
85
+ # Note: Unfiltered!
86
+ def all_acct_amounts
87
+ @all_acct_amounts ||= Journal.acct_amounts_in_documents(journals)
88
+ end
89
+
90
+
91
+ def all_entries
92
+ @all_entries ||= Journal.entries_in_documents(journals)
93
+ end
94
+
95
+
96
+ def receipt_full_filespec(receipt_filespec)
97
+ File.join(run_options.receipt_dir, receipt_filespec)
98
+ end
99
+
100
+
101
+ def missing_and_existing_receipts
102
+ missing = []; existing = []
103
+ all_entries.each do |entry|
104
+ entry.receipts.each do |receipt|
105
+ file_exists = File.file?(receipt_full_filespec(receipt))
106
+ list = (file_exists ? existing : missing)
107
+ list << { receipt: receipt, journal: entry.doc_short_name }
108
+ end
109
+ end
110
+ [missing, existing]
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,113 @@
1
+ require_relative '../types/account'
2
+ require_relative '../types/account_type'
3
+ require_relative '../errors/error'
4
+
5
+ module RockBooks
6
+ class ChartOfAccounts
7
+
8
+ attr_reader :doc_type, :title, :accounts, :entity
9
+
10
+
11
+ def self.from_file(file)
12
+ self.new(File.read(file))
13
+ end
14
+
15
+
16
+ def initialize(input_string)
17
+ @accounts = []
18
+ lines = input_string.split("\n")
19
+ lines.each { |line| parse_line(line) }
20
+ end
21
+
22
+
23
+ def parse_line(line)
24
+ case line.strip
25
+ when /^@doc_type:/
26
+ @doc_type = line.split('@doc_type:').last.strip
27
+ when /^@entity:/
28
+ @entity ||= line.split('@entity:').last.strip
29
+ when /^@title:/
30
+ @title = line.split('@title:').last.strip
31
+ when /^$/
32
+ # ignore empty line
33
+ when /^#/
34
+ # ignore comment line
35
+ else
36
+ # this is an account line in the form: 101 Asset First National City Bank
37
+ # The regex below gets everything before the first whitespace in token 1, and the rest in token 2.
38
+ matcher = line.match(/^(\S+)\s+(.*)$/)
39
+ code = matcher[1]
40
+ rest = matcher[2]
41
+
42
+ matcher = rest.match(/^(\S+)\s+(.*)$/)
43
+ account_type_token = matcher[1]
44
+ account_type = AccountType.to_type(account_type_token).symbol
45
+
46
+ name = matcher[2]
47
+
48
+ accounts << Account.new(code, account_type, name)
49
+ end
50
+ end
51
+
52
+ def accounts_of_type(type)
53
+ accounts.select { |account| account.type == type }
54
+ end
55
+
56
+ def account_codes_of_type(type)
57
+ accounts_of_type(type).map(&:code)
58
+ end
59
+
60
+
61
+ def include?(candidate_code)
62
+ accounts.any? { |account| account.code == candidate_code }
63
+ end
64
+
65
+
66
+ def report_string
67
+ result = ''
68
+
69
+ if title
70
+ result << title << "\n\n"
71
+ end
72
+
73
+ code_width = @accounts.inject(0) { |width, a| width = [width, a.code.length].max }
74
+ format_string = "%-#{code_width}s %-10.10s %s\n"
75
+ accounts.each { |a| result << (format_string % [a.code, a.type.to_s, a.name]) }
76
+
77
+ result
78
+ end
79
+
80
+
81
+ def account_for_code(code)
82
+ accounts.detect { |a| a.code == code }
83
+ end
84
+
85
+
86
+ def type_for_code(code)
87
+ found = account_for_code(code)
88
+ found ? found.type : nil
89
+ end
90
+
91
+ def name_for_code(code)
92
+ found = account_for_code(code)
93
+ found ? found.name : nil
94
+ end
95
+
96
+
97
+ def max_account_code_length
98
+ @max_account_code_length ||= accounts.map { |a| a.code.length }.max
99
+ end
100
+
101
+
102
+ def debit_or_credit_for_code(code)
103
+ type = type_for_code(code)
104
+ if %i(asset expense).include?(type)
105
+ :debit
106
+ elsif %i(liability equity income).include?(type)
107
+ :credit
108
+ else
109
+ raise "Unexpected type #{type} for code #{code}."
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,161 @@
1
+ require 'date'
2
+ require 'json'
3
+ require 'yaml'
4
+
5
+ require_relative '../errors/account_not_found_error'
6
+ require_relative '../types/acct_amount'
7
+ require_relative 'journal_entry'
8
+ require_relative 'journal_entry_builder'
9
+ require_relative '../reports/reporter'
10
+
11
+ module RockBooks
12
+
13
+ # The journal will create journal entries, each of which containing an array of account/amount objects,
14
+ # copying the entry date to them.
15
+ #
16
+ # Warning: Any line beginning with a number will be assumed to be the date of a data line for an entry,
17
+ # so descriptions cannot begin with a number.
18
+ class Journal
19
+
20
+ def self.from_file(chart_of_accounts, file)
21
+ self.new(chart_of_accounts, File.read(file))
22
+ end
23
+
24
+
25
+ # Returns the entries in the specified documents, sorted by date and document short name,
26
+ # optionally filtered with the specified filter.
27
+ def self.entries_in_documents(documents, filter = nil)
28
+ entries = documents.each_with_object([]) do |document, entries|
29
+ entries << document.entries
30
+ end.flatten
31
+
32
+ if filter
33
+ entries = entries.select {|entry| filter.(entry) }
34
+ end
35
+
36
+ entries.sort_by do |entry|
37
+ [entry.date, entry.doc_short_name]
38
+ end
39
+ end
40
+
41
+
42
+
43
+
44
+ def self.acct_amounts_in_documents(documents, entries_filter = nil, acct_amounts_filter = nil)
45
+ entries = entries_in_documents(documents, entries_filter)
46
+
47
+ acct_amounts = entries.each_with_object([]) do |entry, acct_amounts|
48
+ acct_amounts << entry.acct_amounts
49
+ end.flatten
50
+
51
+ if acct_amounts_filter
52
+ acct_amounts = AcctAmount.filter(acct_amounts, filter)
53
+ end
54
+
55
+ acct_amounts
56
+ end
57
+
58
+
59
+ class Entry < Struct.new(:date, :amount, :acct_amounts, :description); end
60
+
61
+ attr_reader :short_name, :account_code, :chart_of_accounts, :date_prefix, :debit_or_credit, :doc_type, :title, :entries
62
+
63
+ # short_name is a name that will appear on reports identifying the journal from which a transaction comes
64
+ def initialize(chart_of_accounts, input_string, short_name = nil)
65
+ @chart_of_accounts = chart_of_accounts
66
+ @short_name = short_name
67
+ @entries = []
68
+ @date_prefix = ''
69
+ @title = ''
70
+ lines = input_string.split("\n")
71
+ lines.each { |line| parse_line(line) }
72
+ end
73
+
74
+
75
+ def parse_line(line)
76
+ case line.strip
77
+ when /^@doc_type:/
78
+ @doc_type = line.split(/^@doc_type:/).last.strip
79
+ when /^@account_code:/
80
+ @account_code = line.split(/^@account_code:/).last.strip
81
+
82
+ unless chart_of_accounts.include?(@account_code)
83
+ raise AccountNotFoundError.new(@account_code)
84
+ end
85
+
86
+ # if debit or credit has not yet been specified, inherit the setting from the account:
87
+ unless @debit_or_credit
88
+ @debit_or_credit = chart_of_accounts.debit_or_credit_for_code(@account_code)
89
+ end
90
+
91
+ when /^@title:/
92
+ @title = line.split(/^@title:/).last.strip
93
+ when /^@short_name:/
94
+ @short_name = line.split(/^@short_name:/).last.strip
95
+ when /^@date_prefix:/
96
+ @date_prefix = line.split(/^@date_prefix:/).last.strip
97
+ when /^@debit_or_credit:/
98
+ data = line.split(/^@debit_or_credit:/).last.strip
99
+ @debit_or_credit = data.to_sym
100
+ when /^$/
101
+ # ignore empty line
102
+ when /^#/
103
+ # ignore comment line
104
+ when /^\d/ # a date/acct/amount line starting with a number
105
+ entries << JournalEntryBuilder.new(line, self).build
106
+ else # Text line(s) to be attached to the most recently parsed transaction
107
+ unless entries.last
108
+ raise Error.new("Entry for this description cannot be found: #{line}")
109
+ end
110
+ entries.last.description << line << "\n"
111
+
112
+ if /^Receipt:/.match(line)
113
+ receipt_spec = line.split(/^Receipt:/).last.strip
114
+ entries.last.receipts << receipt_spec
115
+ end
116
+ end
117
+ end
118
+
119
+
120
+
121
+ def acct_amounts
122
+ entries.each_with_object([]) { |entry, acct_amounts| acct_amounts << entry.acct_amounts }.flatten
123
+ end
124
+
125
+
126
+ def totals_by_account
127
+ acct_amounts.each_with_object(Hash.new(0)) { |aa, totals| totals[aa.code] += aa.amount }
128
+ end
129
+
130
+
131
+ def total_amount
132
+ AcctAmount.total_amount(acct_amounts)
133
+ end
134
+
135
+
136
+ def to_s
137
+ super.to_s + ': ' + \
138
+ {
139
+ account_code: account_code,
140
+ debit_or_credit: debit_or_credit,
141
+ title: title
142
+ }.to_s
143
+ end
144
+
145
+
146
+ def to_h
147
+ {
148
+ title: title,
149
+ account_code: account_code,
150
+ debit_or_credit: debit_or_credit,
151
+ doc_type: doc_type,
152
+ date_prefix: date_prefix,
153
+ entries: entries
154
+ }
155
+ end
156
+
157
+
158
+ def to_json; to_h.to_json; end
159
+ def to_yaml; to_h.to_yaml; end
160
+ end
161
+ end
@@ -0,0 +1,73 @@
1
+ require_relative '../types/acct_amount'
2
+ require_relative '../filters/acct_amount_filters'
3
+
4
+ module RockBooks
5
+
6
+ class JournalEntry < Struct.new(:date, :acct_amounts, :doc_short_name, :description, :receipts)
7
+
8
+
9
+ def initialize(date, acct_amounts = [], doc_short_name = nil, description = '', receipts = [])
10
+ super
11
+ end
12
+
13
+
14
+ def self.entries_acct_amounts(entries)
15
+ acct_amounts = entries.each_with_object([]) do |entry, acct_amounts|
16
+ acct_amounts << entry.acct_amounts
17
+ end
18
+ acct_amounts.flatten!
19
+ acct_amounts
20
+ end
21
+
22
+
23
+ def self.entries_containing_account_code(entries, account_code)
24
+ entries.select { |entry| entry.contains_account?(account_code) }
25
+ end
26
+
27
+
28
+ def self.total_for_code(entries, account_code)
29
+ entries.map { |entry| entry.total_for_code(account_code)}.sum
30
+ end
31
+
32
+
33
+ def self.sort_entries_by_amount_descending!(entries)
34
+ entries.sort_by! do |entry|
35
+ [entry.total_absolute_value, entry.doc_short_name]
36
+ end
37
+ entries.reverse!
38
+ end
39
+
40
+
41
+ def total_for_code(account_code)
42
+ acct_amounts_with_code(account_code).map(&:amount).sum
43
+ end
44
+
45
+
46
+ def acct_amounts_with_code(account_code)
47
+ AcctAmount.filter(acct_amounts, AcctAmountFilters.account_code(account_code))
48
+ end
49
+
50
+
51
+ def total_amount
52
+ acct_amounts.inject(0) { |sum, aa| sum + aa.amount }
53
+ end
54
+
55
+
56
+ # Gets the absolute value of the positive (or negative) amounts in this entry.
57
+ # This is used to sort by transaction amount, since total of all amounts will always be zero.
58
+ def total_absolute_value
59
+ acct_amounts.map(&:amount).select { |n| n.positive? }.sum
60
+ end
61
+
62
+
63
+ def balanced?
64
+ total_amount == 0.0
65
+ end
66
+
67
+
68
+ def contains_account?(account_code)
69
+ acct_amounts.any? { |acct_amount| acct_amount.code == account_code }
70
+ end
71
+ end
72
+
73
+ end