rock_books 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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