rock_books 0.5.0 → 0.8.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/RELEASE_NOTES.md +35 -0
  4. data/assets/fonts/JetBrainsMono-Medium.ttf +0 -0
  5. data/lib/rock_books/cmd_line/command_line_interface.rb +6 -6
  6. data/lib/rock_books/cmd_line/main.rb +1 -9
  7. data/lib/rock_books/documents/book_set.rb +4 -143
  8. data/lib/rock_books/documents/chart_of_accounts.rb +29 -12
  9. data/lib/rock_books/documents/journal.rb +3 -8
  10. data/lib/rock_books/documents/journal_entry.rb +7 -2
  11. data/lib/rock_books/documents/journal_entry_builder.rb +4 -0
  12. data/lib/rock_books/helpers/book_set_loader.rb +3 -3
  13. data/lib/rock_books/reports/balance_sheet.rb +9 -43
  14. data/lib/rock_books/reports/book_set_reporter.rb +218 -0
  15. data/lib/rock_books/reports/data/bs_is_data.rb +61 -0
  16. data/lib/rock_books/reports/data/bs_is_section_data.rb +28 -0
  17. data/lib/rock_books/reports/data/journal_data.rb +37 -0
  18. data/lib/rock_books/reports/data/multidoc_txn_by_account_data.rb +40 -0
  19. data/lib/rock_books/reports/data/multidoc_txn_report_data.rb +39 -0
  20. data/lib/rock_books/reports/data/receipts_report_data.rb +47 -0
  21. data/lib/rock_books/reports/data/tx_one_account_data.rb +37 -0
  22. data/lib/rock_books/reports/helpers/erb_helper.rb +21 -0
  23. data/lib/rock_books/reports/helpers/receipts_hyperlink_converter.rb +47 -0
  24. data/lib/rock_books/reports/helpers/text_report_helper.rb +142 -0
  25. data/lib/rock_books/reports/income_statement.rb +9 -47
  26. data/lib/rock_books/reports/index_html_page.rb +25 -0
  27. data/lib/rock_books/reports/journal_report.rb +72 -0
  28. data/lib/rock_books/reports/multidoc_txn_by_account_report.rb +32 -0
  29. data/lib/rock_books/reports/multidoc_txn_report.rb +25 -0
  30. data/lib/rock_books/reports/receipts_report.rb +6 -55
  31. data/lib/rock_books/reports/templates/html/index.html.erb +144 -0
  32. data/lib/rock_books/reports/templates/html/report_page.html.erb +13 -0
  33. data/lib/rock_books/reports/templates/text/_receipt_section.txt.erb +17 -0
  34. data/lib/rock_books/reports/templates/text/_totals.txt.erb +8 -0
  35. data/lib/rock_books/reports/templates/text/balance_sheet.txt.erb +22 -0
  36. data/lib/rock_books/reports/templates/text/income_statement.txt.erb +22 -0
  37. data/lib/rock_books/reports/templates/text/journal.txt.erb +20 -0
  38. data/lib/rock_books/reports/templates/text/multidoc_txn_by_account_report.txt.erb +30 -0
  39. data/lib/rock_books/reports/templates/text/multidoc_txn_report.txt.erb +24 -0
  40. data/lib/rock_books/reports/templates/text/receipts_report.txt.erb +15 -0
  41. data/lib/rock_books/reports/templates/text/tx_one_account.txt.erb +20 -0
  42. data/lib/rock_books/reports/tx_one_account.rb +10 -45
  43. data/lib/rock_books/types/account.rb +13 -1
  44. data/lib/rock_books/types/account_type.rb +18 -7
  45. data/lib/rock_books/version.rb +2 -1
  46. data/rock_books.gemspec +5 -3
  47. metadata +65 -17
  48. data/lib/rock_books/documents/index.html.erb +0 -156
  49. data/lib/rock_books/documents/receipts.html.erb +0 -54
  50. data/lib/rock_books/helpers/html_helper.rb +0 -29
  51. data/lib/rock_books/reports/multidoc_transaction_report.rb +0 -66
  52. data/lib/rock_books/reports/reporter.rb +0 -118
  53. data/lib/rock_books/reports/transaction_report.rb +0 -105
  54. data/lib/rock_books/reports/tx_by_account.rb +0 -82
@@ -32,12 +32,17 @@ class JournalEntry < Struct.new(:date, :acct_amounts, :doc_short_name, :descript
32
32
 
33
33
  def self.sort_entries_by_amount_descending!(entries)
34
34
  entries.sort_by! do |entry|
35
- [entry.total_absolute_value, entry.doc_short_name]
35
+ [-entry.total_absolute_value, entry.doc_short_name]
36
36
  end
37
- entries.reverse!
38
37
  end
39
38
 
40
39
 
40
+ def self.sort_entries_by_date!(entries)
41
+ entries.sort_by! { |entry| [entry.date, entry.doc_short_name] }
42
+ end
43
+
44
+
45
+
41
46
  def total_for_code(account_code)
42
47
  acct_amounts_with_code(account_code).map(&:amount).sum
43
48
  end
@@ -14,6 +14,10 @@ class JournalEntryBuilder < Struct.new(:journal_entry_context)
14
14
  def chart_of_accounts; journal_entry_context.chart_of_accounts; end
15
15
 
16
16
 
17
+ # A "token" in this context means an account name and account amount pair, e.g. "pnc.checking 1234.56".
18
+ # @param tokens the account name/amount pairs found in the source text
19
+ # @date transaction date
20
+ # @return array of AcctAmount instances
17
21
  def acct_amounts_from_tokens(tokens, date)
18
22
  acct_amounts = []
19
23
 
@@ -7,6 +7,7 @@ module RockBooks
7
7
 
8
8
  module_function
9
9
 
10
+ # @return a hash whose keys are the filespecs and values are the document types
10
11
  def get_files_with_types(directory)
11
12
  files = Dir[File.join(directory, '*.txt')]
12
13
  files.each_with_object({}) do |filespec, files_with_types|
@@ -42,7 +43,7 @@ module RockBooks
42
43
 
43
44
  # Uses all *.txt files in the specified directory; uses @doc_type to determine which
44
45
  # is the chart of accounts and which are journals.
45
- # To exclude a file, make the extension other than .rdt.
46
+ # To exclude a file, make the extension something other than .txt.
46
47
  def load(run_options)
47
48
 
48
49
  files_with_types = get_files_with_types(run_options.input_dir)
@@ -54,9 +55,8 @@ module RockBooks
54
55
  validate_journal_file_count(journal_files)
55
56
 
56
57
  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
+ journals = journal_files.map { |filespec| Journal.from_file(chart_of_accounts, filespec) }
58
59
  BookSet.new(run_options, chart_of_accounts, journals)
59
60
  end
60
-
61
61
  end
62
62
  end
@@ -1,6 +1,5 @@
1
- require_relative '../filters/journal_entry_filters'
2
- require_relative '../documents/journal'
3
- require_relative 'report_context'
1
+ require_relative 'helpers/erb_helper'
2
+ require_relative 'helpers/text_report_helper'
4
3
 
5
4
  module RockBooks
6
5
 
@@ -9,52 +8,19 @@ module RockBooks
9
8
  # in order to calculate the correct balances, so we ignore the global $filter.
10
9
  class BalanceSheet
11
10
 
12
- include Reporter
11
+ include TextReportHelper
12
+ include ErbHelper
13
13
 
14
- attr_accessor :context
14
+ attr_accessor :context, :data
15
15
 
16
- def initialize(report_context)
16
+ def initialize(report_context, data)
17
17
  @context = report_context
18
+ @data = data
18
19
  end
19
20
 
20
21
 
21
- def end_date
22
- context.chart_of_accounts.end_date
22
+ def generate
23
+ ErbHelper.render_hashes('text/balance_sheet.txt.erb', data, template_presentation_context)
23
24
  end
24
-
25
-
26
- def generate_header
27
- lines = [banner_line]
28
- lines << center(context.entity || 'Unspecified Entity')
29
- lines << center("Balance Sheet for Period Ending #{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
25
  end
60
26
  end
@@ -0,0 +1,218 @@
1
+ require_relative '../documents/book_set'
2
+
3
+ require_relative 'balance_sheet'
4
+ require_relative 'data/bs_is_data'
5
+ require_relative 'data/receipts_report_data'
6
+ require_relative 'income_statement'
7
+ require_relative 'index_html_page'
8
+ require_relative 'multidoc_txn_report'
9
+ require_relative 'receipts_report'
10
+ require_relative 'report_context'
11
+ require_relative 'journal_report'
12
+ require_relative 'multidoc_txn_by_account_report'
13
+ require_relative 'tx_one_account'
14
+ require_relative 'helpers/erb_helper'
15
+ require_relative 'helpers/text_report_helper'
16
+ require_relative 'helpers/receipts_hyperlink_converter'
17
+
18
+ require 'prawn'
19
+
20
+ module RockBooks
21
+ class BookSetReporter
22
+
23
+ extend Forwardable
24
+
25
+ attr_reader :book_set, :context, :filter, :output_dir
26
+
27
+ def_delegator :book_set, :all_entries
28
+ def_delegator :book_set, :journals
29
+ def_delegator :book_set, :chart_of_accounts
30
+ def_delegator :book_set, :run_options
31
+
32
+ FONT_FILESPEC = File.absolute_path(File.join(File.dirname(__FILE__), '..', '..', '..', 'assets', 'fonts', 'JetBrainsMono-Medium.ttf'))
33
+
34
+
35
+ def initialize(book_set, output_dir, filter = nil)
36
+ @book_set = book_set
37
+ @output_dir = output_dir
38
+ @filter = filter
39
+ @context = ReportContext.new(book_set.chart_of_accounts, book_set.journals, 80)
40
+ end
41
+
42
+
43
+ def generate
44
+ create_directories
45
+ create_index_html
46
+
47
+ do_statements
48
+ do_journals
49
+ do_transaction_reports
50
+ do_single_account_reports
51
+ do_receipts_report
52
+ end
53
+
54
+
55
+ # All methods after this point are private.
56
+
57
+ private def do_statements
58
+ bs_is_data = BsIsData.new(context)
59
+
60
+ bal_sheet_text_report = BalanceSheet.new(context, bs_is_data.bal_sheet_data).generate
61
+ write_report(:balance_sheet, bal_sheet_text_report)
62
+
63
+ inc_stat_text_report = IncomeStatement.new(context, bs_is_data.inc_stat_data).generate
64
+ write_report(:income_statement, inc_stat_text_report)
65
+ end
66
+
67
+
68
+ private def do_journals
69
+ journals.each do |journal|
70
+ report_data = JournalData.new(journal, context, filter).fetch
71
+ text_report = JournalReport.new(report_data, context, filter).generate
72
+ write_report(journal.short_name, text_report)
73
+ end
74
+ end
75
+
76
+
77
+ private def do_transaction_reports
78
+
79
+ do_date_or_amount_report = ->(sort_field, short_name) do
80
+ data = MultidocTxnReportData.new(context, sort_field, filter).fetch
81
+ text_report = MultidocTransactionReport.new(data, context).generate
82
+ write_report(short_name, text_report)
83
+ end
84
+
85
+ do_acct_report = -> do
86
+ data = MultidocTxnByAccountData.new(context).fetch
87
+ text_report = MultidocTransactionByAccountReport.new(data, context).generate
88
+ write_report(:all_txns_by_acct, text_report)
89
+ end
90
+
91
+ do_date_or_amount_report.(:date, :all_txns_by_date)
92
+ do_date_or_amount_report.(:amount, :all_txns_by_amount)
93
+ do_acct_report.()
94
+ end
95
+
96
+
97
+ private def do_single_account_reports
98
+ chart_of_accounts.accounts.each do |account|
99
+ short_name = ('acct_' + account.code).to_sym
100
+ data = TxOneAccountData.new(context, account.code).fetch
101
+ text_report = TxOneAccount.new(data, context).generate
102
+ write_report(short_name, text_report)
103
+ end
104
+ end
105
+
106
+
107
+ private def do_receipts_report
108
+ data = ReceiptsReportData.new(book_set.all_entries, run_options.receipt_dir).fetch
109
+ text_report = ReceiptsReport.new(context, data).generate
110
+ write_report(:receipts, text_report)
111
+ end
112
+
113
+
114
+ private def create_directories
115
+ %w(txt pdf html).each do |format|
116
+ dir = File.join(output_dir, format, SINGLE_ACCT_SUBDIR)
117
+ FileUtils.mkdir_p(dir)
118
+ end
119
+ end
120
+
121
+
122
+ # "./pdf/short_name.pdf" or "./pdf/single_account/short_name.pdf"
123
+ private def build_filespec(directory, short_name, file_format)
124
+ fragments = [directory, file_format, "#{short_name}.#{file_format}"]
125
+ is_acct_report = /^acct_/.match(short_name)
126
+ if is_acct_report
127
+ fragments.insert(2, SINGLE_ACCT_SUBDIR)
128
+ end
129
+ File.join(*fragments)
130
+ end
131
+
132
+
133
+ private def report_metadata(doc_short_name)
134
+ {
135
+ RBCreator: "RockBooks v#{VERSION} (#{PROJECT_URL})",
136
+ RBEntity: context.entity,
137
+ RBCreated: Time.now.to_s,
138
+ RBDocumentCode: doc_short_name.to_s,
139
+ }
140
+ end
141
+
142
+
143
+ private def prawn_create_document(pdf_filespec, report_text, doc_short_name)
144
+ Prawn::Document.generate(pdf_filespec, info: report_metadata(doc_short_name)) do
145
+ font(FONT_FILESPEC, size: 10)
146
+
147
+ utf8_nonbreaking_space = "\uC2A0"
148
+ unicode_nonbreaking_space = "\u00A0"
149
+ text(report_text.gsub(' ', unicode_nonbreaking_space))
150
+ end
151
+ end
152
+
153
+
154
+ private def html_metadata_comment(doc_short_name)
155
+ "\n" + report_metadata(doc_short_name).ai(plain: true) + "\n"
156
+ end
157
+
158
+
159
+ private def write_report(short_name, text_report)
160
+
161
+ txt_filespec = build_filespec(output_dir, short_name, 'txt')
162
+ html_filespec = build_filespec(output_dir, short_name, 'html')
163
+ pdf_filespec = build_filespec(output_dir, short_name, 'pdf')
164
+
165
+ create_text_report = -> { File.write(txt_filespec, text_report) }
166
+
167
+ create_pdf_report = -> { prawn_create_document(pdf_filespec, text_report, short_name) }
168
+
169
+ create_html_report = -> do
170
+ data = {
171
+ report_body: text_report,
172
+ title: "#{short_name} Report -- RockBooks",
173
+ metadata_comment: html_metadata_comment(short_name)
174
+ }
175
+ html_raw_report = ErbHelper.render_hashes("html/report_page.html.erb", data, {})
176
+ html_report = ReceiptsHyperlinkConverter.convert(html_raw_report, html_filespec)
177
+ File.write(html_filespec, html_report)
178
+ end
179
+
180
+ create_text_report.()
181
+ create_pdf_report.()
182
+ create_html_report.()
183
+
184
+ puts "Created text, PDF, and HTML reports for #{short_name}."
185
+ end
186
+
187
+
188
+ private def missing_existing_unused_receipts
189
+ missing_receipts = []
190
+ existing_receipts = []
191
+ receipt_full_filespec = ->(receipt_filespec) { File.join(run_options.receipt_dir, receipt_filespec) }
192
+
193
+ # We will start out putting all filespecs in the unused array, and delete them as they are found in the transactions.
194
+ unused_receipt_filespecs = Dir['receipts/**/*'].select { |s| File.file?(s) } \
195
+ .sort \
196
+ .map { |s| "./" + s } # Prepend './' to match the data
197
+
198
+ all_entries.each do |entry|
199
+ entry.receipts.each do |receipt|
200
+ filespec = receipt_full_filespec.(receipt)
201
+ unused_receipt_filespecs.delete(filespec)
202
+ file_exists = File.file?(filespec)
203
+ list = (file_exists ? existing_receipts : missing_receipts)
204
+ list << { receipt: receipt, journal: entry.doc_short_name }
205
+ end
206
+ end
207
+ [missing_receipts, existing_receipts, unused_receipt_filespecs]
208
+ end
209
+
210
+
211
+ private def create_index_html
212
+ filespec = build_filespec(output_dir, 'index', 'html')
213
+ content = IndexHtmlPage.new(context, html_metadata_comment('index.html'), run_options).generate
214
+ File.write(filespec, content)
215
+ puts "Created index.html"
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,61 @@
1
+ require_relative 'bs_is_section_data'
2
+ require_relative '../../filters/journal_entry_filters'
3
+ require_relative '../../documents/journal'
4
+ require_relative '../report_context'
5
+
6
+ module RockBooks
7
+
8
+ class BsIsData
9
+
10
+ attr_reader :journals_acct_totals, :context, :start_date, :end_date, :totals
11
+
12
+ def initialize(context)
13
+ @context = context
14
+ @start_date = context.chart_of_accounts.start_date
15
+ @end_date = context.chart_of_accounts.end_date
16
+ filter = JournalEntryFilters.date_on_or_before(end_date)
17
+ acct_amounts = Journal.acct_amounts_in_documents(context.journals, filter)
18
+ @journals_acct_totals = AcctAmount.aggregate_amounts_by_account(acct_amounts)
19
+ end
20
+
21
+
22
+ def section_data(type)
23
+ BsIsSectionData.new(type, context, journals_acct_totals).fetch
24
+ end
25
+
26
+
27
+ def bal_sheet_data
28
+ {
29
+ end_date: end_date,
30
+ entity: context.entity,
31
+ sections: {
32
+ asset: section_data(:asset),
33
+ liability: section_data(:liability),
34
+ equity: section_data(:equity),
35
+ },
36
+ grand_total: journals_acct_totals.values.sum.round(2)
37
+ }
38
+ end
39
+
40
+
41
+ def inc_stat_data
42
+ income_section_data = section_data(:income)
43
+ expense_section_data = section_data(:expense)
44
+ net_income = (income_section_data[:acct_totals].values.sum.round(2) -
45
+ expense_section_data[:acct_totals].values.sum.round(2)
46
+ ).round(2)
47
+
48
+ {
49
+ start_date: start_date,
50
+ end_date: end_date,
51
+ entity: context.entity,
52
+ sections: {
53
+ income: income_section_data,
54
+ expense: expense_section_data,
55
+ },
56
+ net_income: net_income
57
+ }
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ module RockBooks
2
+
3
+ BsIsSectionData = Struct.new(:type, :context, :journals_acct_totals)
4
+ class BsIsSectionData
5
+
6
+ def fetch
7
+ {
8
+ acct_totals: totals,
9
+ total: totals.map(&:last).sum.round(2)
10
+ }
11
+ end
12
+
13
+ private def totals
14
+ @totals ||= calc_section_acct_totals
15
+ end
16
+
17
+ private def calc_section_acct_totals
18
+ codes = context.chart_of_accounts.account_codes_of_type(type)
19
+ totals = journals_acct_totals.select { |code, _amount| codes.include?(code) }
20
+ need_to_reverse_sign = %i{liability equity income}.include?(type)
21
+ if need_to_reverse_sign
22
+ totals.keys.each { |code| totals[code] = -totals[code] }
23
+ end
24
+ totals
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ require_relative '../../types/acct_amount'
2
+
3
+ module RockBooks
4
+ class JournalData
5
+
6
+ attr_reader :journal, :context, :filter
7
+
8
+ def initialize(journal, report_context, filter = nil)
9
+ @journal = journal
10
+ @context = report_context
11
+ @filter = filter
12
+ end
13
+
14
+ def entries
15
+ return @entries if @entries
16
+ @entries = journal.entries
17
+ @entries = @entries.select { |entry| filter.(entry) } if filter
18
+ @entries
19
+ end
20
+
21
+ def fetch
22
+ totals = AcctAmount.aggregate_amounts_by_account(JournalEntry.entries_acct_amounts(entries))
23
+ {
24
+ code: journal.account_code,
25
+ name: journal.chart_of_accounts.name_for_code(journal.account_code),
26
+ title: journal.title,
27
+ short_name: journal.short_name,
28
+ start_date: context.chart_of_accounts.start_date,
29
+ end_date: context.chart_of_accounts.end_date,
30
+ entries: entries,
31
+ totals: totals,
32
+ grand_total: totals.values.sum.round(2),
33
+ max_acct_code_len: context.chart_of_accounts.max_account_code_length
34
+ }
35
+ end
36
+ end
37
+ end