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,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