rock_books 0.5.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
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