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,148 @@
1
+ require_relative '../types/acct_amount'
2
+ require_relative 'chart_of_accounts'
3
+ require_relative 'journal'
4
+
5
+ module RockBooks
6
+ class JournalEntryBuilder < Struct.new(:line, :journal)
7
+
8
+ def acct_amounts_from_tokens(tokens, date, chart_of_accounts)
9
+ acct_amounts = []
10
+
11
+ tokens[0..-1].each_slice(2).each do |(account_code, amount)|
12
+ begin
13
+ acct_amount = AcctAmount.create_with_chart_validation(date, account_code, amount, chart_of_accounts)
14
+ rescue AccountNotFoundError => error
15
+ error.document_name = journal.short_name
16
+ error.line = line
17
+ raise
18
+ end
19
+
20
+ acct_amounts << acct_amount
21
+ end
22
+
23
+ acct_amounts
24
+ end
25
+
26
+
27
+ def validate_acct_amount_token_array_size(tokens)
28
+ if tokens.size.odd?
29
+ raise Error.new("Incorrect sequence of account codes and amounts: #{tokens}")
30
+ end
31
+ end
32
+
33
+
34
+ def convert_amounts_to_floats(tokens)
35
+ (1...tokens.size).step(2) do |amount_index|
36
+ tokens[amount_index] = Float(tokens[amount_index])
37
+ end
38
+ end
39
+
40
+
41
+ def general_journal?
42
+ journal.doc_type == 'general_journal'
43
+ end
44
+
45
+
46
+ # For regular journal only, not general journal.
47
+ # This converts the entered signs to the correct debit/credit signs.
48
+ def convert_signs_for_debit_credit(tokens)
49
+
50
+ # Adjust the sign of the amount for the main journal account (e.g. the checking account or credit card account)
51
+ # e.g. If it's a checking account, it is an asset, a debit account, and the transaction total
52
+ # will represent a credit to that checking account.
53
+ adjust_sign_for_main_account = ->(amount) do
54
+ (journal.debit_or_credit == :debit) ? -amount : amount
55
+ end
56
+
57
+ adjust_sign_for_other_accounts = ->(amount) do
58
+ - adjust_sign_for_main_account.(amount)
59
+ end
60
+
61
+ tokens[1] = adjust_sign_for_main_account.(tokens[1])
62
+ (3...tokens.size).step(2) do |amount_index|
63
+ tokens[amount_index] = adjust_sign_for_other_accounts.(tokens[amount_index])
64
+ end
65
+ end
66
+
67
+
68
+ # Returns an array of AcctAmount instances for the array of tokens.
69
+ #
70
+ # The following applies only to regular (not general) journals:
71
+ #
72
+ # this token array will start with the transaction's total amount
73
+ # and be followed by account/amount pairs.
74
+ #
75
+ # Examples, assuming a line:
76
+ # 2018-05-20 5.79 701 1.23 702 4.56
77
+ #
78
+ # and the journal account is '101', 'D' 'My Checking Account',
79
+ # the following AcctAmoutns will be created:
80
+ # [AcctAmount code: '101', amount: -5.79, AcctAmount code: '701', 1.23, AcctAmount code: '702', 4.56, ]
81
+ #
82
+ # shortcut: if there is only 1 account (that is, it is not a split entry), give it the total amount
83
+ # ['5.79', '701'] --> [AcctAmount code: '101', amount: -5.79, AcctAmount code: '701', 5.79]
84
+ #
85
+ # If the account is a credit account, the signs will be reversed.
86
+ def build_acct_amount_array(date, tokens)
87
+
88
+ unless general_journal?
89
+ if journal.account_code.nil?
90
+ raise Error.new("An '@account_code: ' line has not yet been specified in this journal." )
91
+ end
92
+
93
+ # Prepend the array with the document account code so that total amount will be associated with it.
94
+ tokens.unshift(journal.account_code)
95
+
96
+ # For convenience in regular journals, when there is no split,
97
+ # we permit the user to omit the amount after the
98
+ # account code, since we know it will be equal to the total amount.
99
+ # We add it here, because we *will* need to include it in the data.
100
+ if tokens.size == 3
101
+ tokens << tokens[1] # copy the total amount to the sole account's amount
102
+ end
103
+ end
104
+
105
+ validate_acct_amount_token_array_size(tokens)
106
+
107
+ # Tokens in the odd numbered positions are dollar amounts that need to be converted from string to float.
108
+ begin
109
+ convert_amounts_to_floats(tokens)
110
+ rescue ArgumentError
111
+ raise Error.new("Float conversion or other parse error for #{date}, #{tokens}.")
112
+ end
113
+
114
+ unless general_journal?
115
+ # As a convenience, all normal journal amounts are entered as positive numbers.
116
+ # This code negates the amounts as necessary so that debits are + and credits are -.
117
+ # In general journals, the debit and credit amounts must be entered correctly already.
118
+ convert_signs_for_debit_credit(tokens)
119
+ end
120
+
121
+ acct_amounts_from_tokens(tokens, date, journal.chart_of_accounts)
122
+ end
123
+
124
+
125
+ def build
126
+ # this is an account line in the form: yyyy-mm-dd 101 blah blah blah
127
+ tokens = line.split
128
+ acct_amount_tokens = tokens[1..-1]
129
+ date_string = journal.date_prefix + tokens[0]
130
+ raise_error = -> do
131
+ raise Error.new("In journal '#{journal.short_name}', date string was '#{date_string}'" +
132
+ " but should be a valid date in the form YYYY-MM-DD.")
133
+ end
134
+
135
+ raise_error.() if date_string.length != 10
136
+
137
+ begin
138
+ date = Date.iso8601(date_string)
139
+ rescue ArgumentError
140
+ raise_error.()
141
+ end
142
+
143
+ acct_amounts = build_acct_amount_array(date, acct_amount_tokens)
144
+ JournalEntry.new(date, acct_amounts, journal.short_name)
145
+ end
146
+
147
+ end
148
+ end
@@ -0,0 +1,20 @@
1
+ module RockBooks
2
+ class AccountNotFoundError < RuntimeError
3
+
4
+ attr_accessor :bad_account_code, :document_name, :line
5
+
6
+ def initialize(bad_account_code, document_name = nil, line = nil)
7
+ self.bad_account_code = bad_account_code
8
+ super(to_s)
9
+ end
10
+
11
+
12
+ def to_s
13
+ s = "Account code not found in chart of accounts: #{bad_account_code}"
14
+ if document_name && line
15
+ s << ", document: #{document_name}, line: #{line}"
16
+ end
17
+ s
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,10 @@
1
+ # This error class is intended to differentiate errors from this library from other errors
2
+ # when this code is included in external code.
3
+ # In addition, more specific error classes in this library can subclass this one.
4
+
5
+ module RockBooks
6
+
7
+ class Error < RuntimeError
8
+ end
9
+
10
+ end
@@ -0,0 +1,12 @@
1
+ module RockBooks
2
+ module AcctAmountFilters
3
+
4
+ module_function
5
+
6
+ def account_code(code)
7
+ ->(acct_amount) { acct_amount.code == code }
8
+ end
9
+
10
+
11
+ end
12
+ end
@@ -0,0 +1,84 @@
1
+ require 'date'
2
+
3
+ module RockBooks
4
+ module JournalEntryFilters
5
+
6
+ module_function
7
+
8
+
9
+ # Dates can be provided as a Ruby Date object, or as a string that will be converted to date (yyyy-mm-dd).
10
+ def to_date(string_or_date_object)
11
+ if string_or_date_object.is_a?(String)
12
+ Date.iso8601(string_or_date_object)
13
+ else
14
+ string_or_date_object
15
+ end
16
+ end
17
+
18
+
19
+ def null_filter
20
+ ->(entry) { true }
21
+ end
22
+
23
+
24
+ def year(target_year)
25
+ ->(entry) { entry.date.year == target_year }
26
+ end
27
+
28
+
29
+ def month(target_year, target_month)
30
+ ->(entry) do
31
+ entry.date.year == target_year && entry.date.month == target_month
32
+ end
33
+ end
34
+
35
+
36
+ def day(target_year, target_month, target_day)
37
+ ->(entry) do
38
+ entry.date.year == target_year && entry.date.month == target_month && entry.date.day == target_day
39
+ end
40
+ end
41
+
42
+
43
+ def account_code_filter(account_code)
44
+ ->(entry) do
45
+ entry.acct_amounts.map(&:code).detect { |code| code == account_code }
46
+ end
47
+ end
48
+
49
+
50
+ def date_on_or_after(date)
51
+ ->(entry) { entry.date >= to_date(date) }
52
+
53
+ end
54
+
55
+
56
+ def date_on_or_before(date)
57
+ date = to_date(date)
58
+ ->(entry) { entry.date <= date }
59
+ end
60
+
61
+
62
+ def date_in_range(start_date, end_date)
63
+ start_date = to_date(start_date)
64
+ end_date = to_date(end_date)
65
+ ->(entry) { entry.date >= start_date && entry.date <= end_date }
66
+ end
67
+
68
+
69
+ def all(*filters)
70
+ ->(entry) { filters.all? { |filter| filter.(entry) } }
71
+ end
72
+
73
+
74
+ def any(*filters)
75
+ ->(entry) { filters.any? { |filter| filter.(entry) } }
76
+ end
77
+
78
+
79
+ def none(*filters)
80
+ ->(entry) { filters.none? { |filter| filter.(entry) } }
81
+ end
82
+ end
83
+ end
84
+
@@ -0,0 +1,62 @@
1
+ require_relative '../documents/book_set'
2
+
3
+ module RockBooks
4
+
5
+ # Entry point is `load` method. Loads files in a directory to instantiate a BookSet.
6
+ module BookSetLoader
7
+
8
+ module_function
9
+
10
+ def get_files_with_types(directory)
11
+ files = Dir[File.join(directory, '*.rbt')]
12
+ files.each_with_object({}) do |filespec, files_with_types|
13
+ files_with_types[filespec] = ParseHelper.find_document_type_in_file(filespec)
14
+ end
15
+ end
16
+
17
+
18
+ def validate_chart_account_count(chart_of_account_files)
19
+ size = chart_of_account_files.size
20
+
21
+ if size == 0
22
+ raise Error.new("Chart of accounts file not found in input directory.\n" +
23
+ " Does it have a '@doc_type: chart_of_accounts' line?")
24
+ elsif size > 1
25
+ raise Error.new("Expected only 1 chart of accounts file but found: #{chart_of_account_files}.")
26
+ end
27
+ end
28
+
29
+
30
+ def validate_journal_file_count(journal_files)
31
+ if journal_files.size == 0
32
+ raise Error.new("No journal files found in directory #{directory}. " +
33
+ "A journal file must contain the line '@doc_type: journal'")
34
+ end
35
+ end
36
+
37
+
38
+ def select_files_of_type(files_with_types, target_doc_type_regex)
39
+ files_with_types.select { |filespec, doc_type| target_doc_type_regex === doc_type }.keys
40
+ end
41
+
42
+
43
+ # Uses all *.rbt files in the specified directory; uses @doc_type to determine which
44
+ # is the chart of accounts and which are journals.
45
+ # To exclude a file, make the extension other than .rdt.
46
+ def load(run_options)
47
+
48
+ files_with_types = get_files_with_types(run_options.input_dir)
49
+
50
+ chart_of_account_files = select_files_of_type(files_with_types, 'chart_of_accounts')
51
+ validate_chart_account_count(chart_of_account_files)
52
+
53
+ journal_files = select_files_of_type(files_with_types, /journal/) # include 'journal' and 'general_journal'
54
+ validate_journal_file_count(journal_files)
55
+
56
+ chart_of_accounts = ChartOfAccounts.from_file(chart_of_account_files.first)
57
+ journals = journal_files.map { |fs| Journal.from_file(chart_of_accounts, fs) }
58
+ BookSet.new(run_options, chart_of_accounts, journals)
59
+ end
60
+
61
+ end
62
+ end
@@ -0,0 +1,22 @@
1
+ module RockBooks
2
+
3
+ module ParseHelper
4
+
5
+ module_function
6
+
7
+
8
+ def find_document_type(document_lines)
9
+ doc_type_line = document_lines.detect { |line| /^@doc_type: /.match(line) }
10
+ if doc_type_line.nil?
11
+ nil
12
+ else
13
+ doc_type_line.split(/^@doc_type: /).last.strip
14
+ end
15
+ end
16
+
17
+
18
+ def find_document_type_in_file(filespec)
19
+ find_document_type(File.readlines(filespec))
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,60 @@
1
+ require_relative '../filters/journal_entry_filters'
2
+ require_relative '../documents/journal'
3
+ require_relative 'report_context'
4
+
5
+ module RockBooks
6
+
7
+ # Reports the balance sheet as of the specified date.
8
+ # Unlike other reports, we need to process transactions from the beginning of time
9
+ # in order to calculate the correct balances, so we ignore the global $filter.
10
+ class BalanceSheet
11
+
12
+ include Reporter
13
+
14
+ attr_accessor :context
15
+
16
+ def initialize(report_context)
17
+ @context = report_context
18
+ end
19
+
20
+
21
+ def end_date
22
+ context.end_date || Time.new.to_date
23
+ end
24
+
25
+
26
+ def generate_header
27
+ lines = [banner_line]
28
+ lines << center(context.entity || 'Unspecified Entity')
29
+ lines << center("Balance Sheet -- #{end_date}")
30
+ lines << banner_line
31
+ lines << ''
32
+ lines << ''
33
+ lines << ''
34
+ lines.join("\n")
35
+ end
36
+
37
+
38
+ def generate_report
39
+ filter = RockBooks::JournalEntryFilters.date_on_or_before(end_date)
40
+ acct_amounts = Journal.acct_amounts_in_documents(context.journals, filter)
41
+ totals = AcctAmount.aggregate_amounts_by_account(acct_amounts)
42
+ output = generate_header
43
+
44
+ asset_output, asset_total = generate_account_type_section('Assets', totals, :asset, false)
45
+ liab_output, liab_total = generate_account_type_section('Liabilities', totals, :liability, true)
46
+ equity_output, equity_total = generate_account_type_section('Equity', totals, :equity, true)
47
+
48
+ output << [asset_output, liab_output, equity_output].join("\n\n")
49
+
50
+ grand_total = asset_total - (liab_total + equity_total)
51
+
52
+ output << "\n#{"%12.2f Assets - (Liabilities + Equity)" % grand_total}\n============\n"
53
+ output
54
+ end
55
+
56
+ alias_method :to_s, :generate_report
57
+ alias_method :call, :generate_report
58
+
59
+ end
60
+ end
@@ -0,0 +1,63 @@
1
+ require_relative '../documents/journal'
2
+ require_relative 'report_context'
3
+
4
+ module RockBooks
5
+
6
+
7
+ class IncomeStatement
8
+
9
+ include Reporter
10
+
11
+ attr_accessor :context
12
+
13
+
14
+ def initialize(report_context)
15
+ @context = report_context
16
+ end
17
+
18
+
19
+ def start_date
20
+ context.start_date || Date.new(1900, 1, 1)
21
+ end
22
+
23
+
24
+ def end_date
25
+ context.end_date || Date.new(2100, 1, 1)
26
+ end
27
+
28
+
29
+ def generate_header
30
+ lines = [banner_line]
31
+ lines << center(context.entity || 'Unspecified Entity')
32
+ lines << "#{center("Income Statement -- #{start_date} to #{end_date}")}"
33
+ lines << banner_line
34
+ lines << ''
35
+ lines << ''
36
+ lines << ''
37
+ lines.join("\n")
38
+ end
39
+
40
+
41
+ def generate_report
42
+ filter = RockBooks::JournalEntryFilters.date_in_range(start_date, end_date)
43
+ acct_amounts = Journal.acct_amounts_in_documents(context.journals, filter)
44
+ totals = AcctAmount.aggregate_amounts_by_account(acct_amounts)
45
+ totals.each { |aa| aa[1] = -aa[1] } # income statement shows credits as positive, debits as negative
46
+ output = generate_header
47
+
48
+ income_output, income_total = generate_account_type_section('Income', totals, :income, true)
49
+ expense_output, expense_total = generate_account_type_section('Expenses', totals, :expense, false)
50
+
51
+ grand_total = income_total - expense_total
52
+
53
+ output << [income_output, expense_output].join("\n\n")
54
+ output << "\n#{"%12.2f Net Income" % grand_total}\n============\n"
55
+ output
56
+ end
57
+
58
+ alias_method :to_s, :generate_report
59
+ alias_method :call, :generate_report
60
+
61
+
62
+ end
63
+ end
@@ -0,0 +1,66 @@
1
+ require_relative '../documents/journal'
2
+ require_relative 'reporter'
3
+ require_relative 'report_context'
4
+
5
+ module RockBooks
6
+
7
+ class MultidocTransactionReport
8
+
9
+ include Reporter
10
+
11
+ attr_accessor :context
12
+
13
+ SORT_BY_VALID_OPTIONS = %i(date_and_account amount)
14
+
15
+ def initialize(report_context)
16
+ @context = report_context
17
+ end
18
+
19
+
20
+ def generate_header(sort_by)
21
+ lines = [banner_line]
22
+ lines << center(context.entity || 'Unspecified Entity')
23
+ lines << center('Multi Document Transaction Report')
24
+ lines << center('Sorted by Amount Descending') if sort_by == :amount
25
+ lines << ''
26
+ lines << center('Source Documents:')
27
+ lines << ''
28
+ context.journals.each do |document|
29
+ short_name = SHORT_NAME_FORMAT_STRING % document.short_name
30
+ lines << center("#{short_name} -- #{document.title}")
31
+ end
32
+ lines << banner_line
33
+ lines << ''
34
+ lines << ' Date Document Amount Account'
35
+ lines << ' ---- -------- ------ -------'
36
+ lines.join("\n") << "\n\n"
37
+ end
38
+
39
+
40
+ def generate_report(filter = nil, sort_by = :date_and_account)
41
+ unless SORT_BY_VALID_OPTIONS.include?(sort_by)
42
+ raise Error.new("sort_by option '#{sort_by}' not in valid choices of #{SORT_BY_VALID_OPTIONS}.")
43
+ end
44
+
45
+ entries = Journal.entries_in_documents(context.journals, filter)
46
+
47
+ if sort_by == :amount
48
+ JournalEntry.sort_entries_by_amount_descending!(entries)
49
+ end
50
+
51
+ sio = StringIO.new
52
+ sio << generate_header(sort_by)
53
+ entries.each { |entry| sio << format_multidoc_entry(entry) << "\n" }
54
+
55
+ totals = AcctAmount.aggregate_amounts_by_account(JournalEntry.entries_acct_amounts(entries))
56
+ sio << generate_and_format_totals('Totals', totals)
57
+
58
+ sio << "\n"
59
+ sio.string
60
+ end
61
+
62
+
63
+ alias_method :to_s, :generate_report
64
+ alias_method :call, :generate_report
65
+ end
66
+ end
@@ -0,0 +1,57 @@
1
+ require_relative 'report_context'
2
+
3
+
4
+ module RockBooks
5
+ class ReceiptsReport
6
+
7
+ include Reporter
8
+
9
+ attr_reader :context, :missing, :existing
10
+
11
+
12
+ def initialize(report_context, missing, existing)
13
+ @context = report_context
14
+ @missing = missing
15
+ @existing = existing
16
+ end
17
+
18
+
19
+ def generate_header
20
+ lines = [banner_line]
21
+ lines << center(context.entity || 'Unspecified Entity')
22
+ lines << "#{center("Receipts Report")}"
23
+ lines << banner_line
24
+ lines << ''
25
+ lines << ''
26
+ lines << ''
27
+ lines.join("\n")
28
+ end
29
+
30
+
31
+ def receipt_info_line(info)
32
+ "%-16.16s %s\n" % [info[:journal], info[:receipt]]
33
+ end
34
+
35
+
36
+ def column_headings
37
+ format_string = "%-16.16s %s\n"
38
+ (format_string % ['Journal', 'Receipt Filespec']) << (format_string % %w(------- ----------------)) << "\n"
39
+ end
40
+
41
+
42
+ def generate_report
43
+ output = generate_header
44
+
45
+ output << "Missing Receipts:\n\n" << column_headings
46
+ missing.each { |info| output << receipt_info_line(info) }
47
+
48
+ output << "\n\n\nExisting Receipts:\n\n" << column_headings
49
+ existing.each { |info| output << receipt_info_line(info) }
50
+
51
+ output
52
+ end
53
+
54
+ alias_method :to_s, :generate_report
55
+ alias_method :call, :generate_report
56
+ end
57
+ end
@@ -0,0 +1,15 @@
1
+ module RockBooks
2
+
3
+ class ReportContext < Struct.new(
4
+ :chart_of_accounts,
5
+ :journals,
6
+ :start_date,
7
+ :end_date,
8
+ :page_width)
9
+
10
+ def entity
11
+ chart_of_accounts.entity
12
+ end
13
+ end
14
+
15
+ end