fin_it 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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +24 -0
  3. data/CHANGELOG.md +9 -0
  4. data/CONTRIBUTING.md +20 -0
  5. data/LICENSE +21 -0
  6. data/QUICKSTART.md +56 -0
  7. data/README.md +74 -0
  8. data/Rakefile +23 -0
  9. data/SECURITY.md +14 -0
  10. data/assets/fin_it_logo.png +0 -0
  11. data/lib/fin_it/account.rb +120 -0
  12. data/lib/fin_it/calculator/currency_conversion.rb +27 -0
  13. data/lib/fin_it/calculator/date_helpers.rb +53 -0
  14. data/lib/fin_it/calculator/variable_hashing.rb +120 -0
  15. data/lib/fin_it/calculator.rb +480 -0
  16. data/lib/fin_it/categories/category.rb +137 -0
  17. data/lib/fin_it/complex_model.rb +169 -0
  18. data/lib/fin_it/dsl/account_builder.rb +35 -0
  19. data/lib/fin_it/dsl/calculated_builder.rb +87 -0
  20. data/lib/fin_it/dsl/config_builder.rb +58 -0
  21. data/lib/fin_it/dsl/model_builder.rb +938 -0
  22. data/lib/fin_it/dsl/model_template_builder.rb +29 -0
  23. data/lib/fin_it/dsl/plan_builder.rb +52 -0
  24. data/lib/fin_it/dsl/project_inheritance_resolver.rb +46 -0
  25. data/lib/fin_it/dsl/variable_builder.rb +41 -0
  26. data/lib/fin_it/dsl.rb +13 -0
  27. data/lib/fin_it/engine.rb +15 -0
  28. data/lib/fin_it/financial_model/account_balances.rb +99 -0
  29. data/lib/fin_it/financial_model/account_hierarchy.rb +158 -0
  30. data/lib/fin_it/financial_model/category_values.rb +179 -0
  31. data/lib/fin_it/financial_model/currency_helpers.rb +14 -0
  32. data/lib/fin_it/financial_model/date_helpers.rb +58 -0
  33. data/lib/fin_it/financial_model/debugging.rb +353 -0
  34. data/lib/fin_it/financial_model/period_flows.rb +121 -0
  35. data/lib/fin_it/financial_model/validation.rb +85 -0
  36. data/lib/fin_it/financial_model/variable_matching.rb +49 -0
  37. data/lib/fin_it/financial_model.rb +395 -0
  38. data/lib/fin_it/model_template.rb +121 -0
  39. data/lib/fin_it/outputs/base_output.rb +51 -0
  40. data/lib/fin_it/outputs/console_output.rb +1528 -0
  41. data/lib/fin_it/outputs/monthly_console_output.rb +145 -0
  42. data/lib/fin_it/outputs/zaxcel_output.rb +1264 -0
  43. data/lib/fin_it/payment_schedule.rb +112 -0
  44. data/lib/fin_it/plan.rb +159 -0
  45. data/lib/fin_it/reports/balance_sheet.rb +638 -0
  46. data/lib/fin_it/reports/base_report.rb +239 -0
  47. data/lib/fin_it/reports/cash_flow_statement.rb +480 -0
  48. data/lib/fin_it/reports/custom_sheet.rb +436 -0
  49. data/lib/fin_it/reports/income_statement.rb +793 -0
  50. data/lib/fin_it/reports/period_comparison.rb +309 -0
  51. data/lib/fin_it/reports/scenario_comparison.rb +296 -0
  52. data/lib/fin_it/temporal_value.rb +349 -0
  53. data/lib/fin_it/transaction_generator/account_resolver.rb +118 -0
  54. data/lib/fin_it/transaction_generator/cache_management.rb +39 -0
  55. data/lib/fin_it/transaction_generator/date_generation.rb +57 -0
  56. data/lib/fin_it/transaction_generator.rb +357 -0
  57. data/lib/fin_it/version.rb +6 -0
  58. data/lib/fin_it.rb +27 -0
  59. data/test/fin_it/calculator_test.rb +109 -0
  60. data/test/fin_it/complex_model_test.rb +198 -0
  61. data/test/fin_it/debugging_test.rb +112 -0
  62. data/test/fin_it/driver_variables_test.rb +109 -0
  63. data/test/fin_it/dsl_test.rb +581 -0
  64. data/test/fin_it/financial_model_test.rb +196 -0
  65. data/test/fin_it/frequency_test.rb +51 -0
  66. data/test/fin_it/outputs/console_output_test.rb +249 -0
  67. data/test/fin_it/plan_test.rb +281 -0
  68. data/test/fin_it/reports/account_balance_test.rb +232 -0
  69. data/test/fin_it/reports/balance_sheet_test.rb +355 -0
  70. data/test/fin_it/reports/cash_flow_statement_test.rb +234 -0
  71. data/test/fin_it/reports/custom_sheet_test.rb +246 -0
  72. data/test/fin_it/reports/income_statement_test.rb +431 -0
  73. data/test/fin_it/reports/period_comparison_test.rb +226 -0
  74. data/test/fin_it/reports/restaurant_model_test.rb +225 -0
  75. data/test/fin_it/reports/scenario_comparison_test.rb +414 -0
  76. data/test/scripts/generate_demo_reports.rb +47 -0
  77. data/test/scripts/startup_saas_demo.rb +62 -0
  78. data/test/test_helper.rb +25 -0
  79. data/test/verify_accounting_equation.rb +91 -0
  80. metadata +264 -0
@@ -0,0 +1,1528 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module FinIt
6
+ module Outputs
7
+ class ConsoleOutput < BaseOutput
8
+ def generate
9
+ data = @report.generate
10
+ currency = data[:currency] || (data[:metadata] ? data[:metadata][:currency] : nil)
11
+ report_type = data[:report_type] || (data[:metadata] ? data[:metadata][:report_type] : nil)
12
+
13
+ # Handle special report types early since they have different structures
14
+ case report_type
15
+ when "ScenarioComparison"
16
+ output_scenario_comparison(data, currency)
17
+ return nil
18
+ when "PeriodComparison"
19
+ output_period_comparison(data, currency)
20
+ return nil
21
+ when "CustomSheet"
22
+ output_custom_sheet(data)
23
+ return nil
24
+ end
25
+
26
+ # Check if we have multiple periods to display as columns
27
+ periods_data = options[:periods]
28
+ if periods_data
29
+ # Handle hash from period_summary
30
+ if periods_data.is_a?(Hash) && periods_data[:periods]
31
+ periods_array = periods_data[:periods]
32
+ elsif periods_data.is_a?(Array)
33
+ periods_array = periods_data
34
+ else
35
+ periods_array = nil
36
+ end
37
+
38
+ if periods_array && periods_array.length > 1
39
+ output_periods_as_columns(periods_data, report_type, currency)
40
+ return nil
41
+ end
42
+ end
43
+
44
+ puts "=" * 80
45
+ project_name = data[:project] ? " - #{data[:project].to_s.split('_').map(&:capitalize).join(' ')}" : ""
46
+ puts "#{report_type.upcase}#{project_name} (#{currency})"
47
+ puts "Period: #{data[:period][:start]} to #{data[:period][:end]}"
48
+ puts "=" * 80
49
+
50
+ # Handle different report types
51
+ case report_type
52
+ when "BalanceSheet"
53
+ output_balance_sheet(data, currency)
54
+ when "CashFlowStatement"
55
+ output_cash_flow_statement(data, currency)
56
+ else
57
+ output_income_statement(data, currency)
58
+ end
59
+
60
+ puts "=" * 80
61
+
62
+ # Add statement comments if present
63
+ if data[:statement_comments] && data[:statement_comments].any?
64
+ output_statement_comments(data[:statement_comments])
65
+ end
66
+
67
+ # Add monthly breakdown if requested (only for income statements)
68
+ if options[:include_monthly] && report_type != "BalanceSheet"
69
+ puts "\n"
70
+ output_monthly_breakdown(data, currency)
71
+ end
72
+
73
+ # Return the string output if needed
74
+ nil
75
+ end
76
+
77
+ # Output multiple periods as columns (e.g., from generate_monthly or period_summary)
78
+ def output_periods_as_columns(periods_data, report_type, currency)
79
+ # periods_data can be:
80
+ # 1. Array of { period: {...}, report: {...} } from generate_monthly
81
+ # 2. Hash with :periods array from period_summary
82
+
83
+ if periods_data.is_a?(Hash) && periods_data[:periods]
84
+ # From period_summary
85
+ periods = periods_data[:periods]
86
+ frequency = periods_data[:frequency] || :monthly
87
+ else
88
+ # From generate_monthly or similar
89
+ periods = periods_data
90
+ frequency = :monthly
91
+ end
92
+
93
+ return if periods.empty?
94
+
95
+ # Format period labels
96
+ period_labels = periods.map do |p|
97
+ period = p[:period] || p
98
+ start_date = period[:start] || period
99
+ format_period_label(start_date, frequency)
100
+ end
101
+
102
+ # Determine column width
103
+ col_width = [15, 100 / (period_labels.length + 1)].max
104
+ total_width = 40 + (col_width * period_labels.length)
105
+ header_format = "%-40s" + (" %#{col_width-1}s" * period_labels.length)
106
+
107
+ puts "=" * total_width
108
+ puts "#{report_type.upcase} - PERIOD COMPARISON (#{currency})"
109
+ puts "=" * total_width
110
+ puts header_format % (["Item"] + period_labels)
111
+ puts "-" * total_width
112
+
113
+ case report_type
114
+ when "BalanceSheet"
115
+ output_balance_sheet_periods_columns(periods, header_format, currency, col_width)
116
+ when "CashFlowStatement"
117
+ output_cash_flow_statement_periods_columns(periods, header_format, currency, col_width)
118
+ else
119
+ output_income_statement_periods_columns(periods, header_format, currency, col_width)
120
+ end
121
+
122
+ puts "=" * total_width
123
+ end
124
+
125
+ def format_period_label(date, frequency)
126
+ date = date[:start] if date.is_a?(Hash) && date[:start]
127
+ case frequency
128
+ when :monthly
129
+ date.strftime("%b %Y")
130
+ when :quarterly
131
+ quarter = ((date.month - 1) / 3) + 1
132
+ "Q#{quarter} #{date.year}"
133
+ when :annual
134
+ date.year.to_s
135
+ when :weekly
136
+ date.strftime("%b %d, %Y")
137
+ when :daily
138
+ date.strftime("%b %d, %Y")
139
+ else
140
+ date.strftime("%b %Y")
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def output_income_statement(data, currency)
147
+ display_items = data[:display_items] || []
148
+
149
+ # Output display items before income section
150
+ output_display_items(display_items, :before, :income, currency)
151
+
152
+ # Income section
153
+ if data[:sections][:income]
154
+ output_section(data[:sections][:income], currency, "INCOME", show_total: true)
155
+ end
156
+
157
+ # Output display items after income section
158
+ output_display_items(display_items, :after, :income, currency)
159
+
160
+ # COGS section (if separate_cogs mode)
161
+ if data[:sections][:cogs]
162
+ output_display_items(display_items, :before, :cogs, currency)
163
+ output_section(data[:sections][:cogs], currency, "COST OF GOODS SOLD", show_total: true)
164
+ output_display_items(display_items, :after, :cogs, currency)
165
+
166
+ # Gross Margin
167
+ if data[:sections][:gross_margin]
168
+ puts "\n"
169
+ puts "-" * 80
170
+ gross_margin = data[:sections][:gross_margin][:total] || data[:sections][:gross_margin][:value] || 0
171
+ puts "%-60s %20s" % ["GROSS MARGIN", format_currency(gross_margin, currency)]
172
+
173
+ # Output display items after gross margin
174
+ output_display_items(display_items, :after, :gross_margin, currency)
175
+ # Output display items in column next to gross margin
176
+ output_display_items_column(display_items, :gross_margin, currency)
177
+ end
178
+ end
179
+
180
+ # Expense section (operating expenses)
181
+ if data[:sections][:expenses]
182
+ output_display_items(display_items, :before, :expenses, currency)
183
+ section_label = data[:expense_display_mode] == :separate_cogs ? "OPERATING EXPENSES" : "EXPENSES"
184
+ output_section(data[:sections][:expenses], currency, section_label, show_total: true)
185
+ output_display_items(display_items, :after, :expenses, currency)
186
+ end
187
+
188
+ # Net Income
189
+ if data[:sections][:net_income]
190
+ output_display_items(display_items, :before, :net_income, currency)
191
+ puts "\n"
192
+ puts "-" * 80
193
+ net_income = data[:sections][:net_income][:total] || data[:sections][:net_income][:value] || 0
194
+ puts "%-60s %20s" % ["NET INCOME", format_currency(net_income, currency)]
195
+ output_display_items(display_items, :after, :net_income, currency)
196
+ end
197
+ end
198
+
199
+ def output_display_items(display_items, position_type, section_key, currency)
200
+ items = display_items.select { |item| item[position_type] == section_key }
201
+ return if items.empty?
202
+
203
+ # Sort by order
204
+ items = items.sort_by { |item| item[:order] || 0 }
205
+
206
+ items.each do |item|
207
+ value_str = format_display_value(item[:value], item[:format], currency)
208
+ puts "%-60s %20s" % [item[:label], value_str]
209
+ end
210
+ end
211
+
212
+ def output_display_items_column(display_items, section_key, currency)
213
+ items = display_items.select { |item| item[:column] == section_key }
214
+ return if items.empty?
215
+
216
+ # For single period, column items are displayed on the same line or next line
217
+ # This is a simplified version - full column support would require two-pass rendering
218
+ items.each do |item|
219
+ value_str = format_display_value(item[:value], item[:format], currency)
220
+ puts "%-60s %20s" % [" #{item[:label]}", value_str]
221
+ end
222
+ end
223
+
224
+ def output_balance_sheet(data, currency)
225
+ # Assets section
226
+ if data[:sections][:assets]
227
+ output_balance_sheet_section(data[:sections][:assets], currency, "ASSETS")
228
+
229
+ # Show asset subtotals
230
+ if data[:sections][:assets][:current_assets] || data[:sections][:assets][:fixed_assets]
231
+ puts "\n"
232
+ puts "-" * 80
233
+ if data[:sections][:assets][:current_assets]
234
+ puts "%-60s %20s" % ["Current Assets", format_currency(data[:sections][:assets][:current_assets], currency)]
235
+ end
236
+ if data[:sections][:assets][:fixed_assets]
237
+ puts "%-60s %20s" % ["Fixed Assets", format_currency(data[:sections][:assets][:fixed_assets], currency)]
238
+ end
239
+ puts "%-60s %20s" % ["TOTAL ASSETS", format_currency(data[:sections][:assets][:total], currency)]
240
+ end
241
+ end
242
+
243
+ # Liabilities section
244
+ if data[:sections][:liabilities]
245
+ output_balance_sheet_section(data[:sections][:liabilities], currency, "LIABILITIES")
246
+
247
+ # Show liability subtotals
248
+ if data[:sections][:liabilities][:current_liabilities] || data[:sections][:liabilities][:long_term_liabilities]
249
+ puts "\n"
250
+ puts "-" * 80
251
+ if data[:sections][:liabilities][:current_liabilities]
252
+ current_liab = data[:sections][:liabilities][:current_liabilities]
253
+ puts "%-60s %20s" % ["Current Liabilities", format_currency(current_liab, currency)]
254
+ end
255
+ if data[:sections][:liabilities][:long_term_liabilities]
256
+ long_term_liab = data[:sections][:liabilities][:long_term_liabilities]
257
+ puts "%-60s %20s" % ["Long-term Liabilities", format_currency(long_term_liab, currency)]
258
+ end
259
+ total_liab = data[:sections][:liabilities][:total]
260
+ puts "%-60s %20s" % ["TOTAL LIABILITIES", format_currency(total_liab, currency)]
261
+ end
262
+ end
263
+
264
+ # Equity section
265
+ if data[:sections][:equity]
266
+ output_section(data[:sections][:equity], currency, "EQUITY")
267
+ end
268
+
269
+ # Accumulated Net Income section (separate from equity)
270
+ if data[:sections][:accumulated_net_income]
271
+ output_section(data[:sections][:accumulated_net_income], currency, "ACCUMULATED NET INCOME")
272
+ end
273
+
274
+ # Total Liabilities and Equity
275
+ if data[:sections][:total_liabilities_and_equity]
276
+ puts "\n"
277
+ puts "-" * 80
278
+ total = data[:sections][:total_liabilities_and_equity][:total] || data[:sections][:total_liabilities_and_equity][:value] || 0
279
+ puts "%-60s %20s" % ["TOTAL LIABILITIES AND EQUITY", format_currency(total, currency)]
280
+ end
281
+ end
282
+
283
+ def output_cash_flow_statement(data, currency)
284
+ display_mode = data[:display_mode] || :standard
285
+
286
+ if display_mode == :simple
287
+ output_simple_cash_flow(data, currency)
288
+ else
289
+ output_standard_cash_flow(data, currency)
290
+ end
291
+ end
292
+
293
+ def output_standard_cash_flow(data, currency)
294
+ sections = data[:sections] || {}
295
+
296
+ # Operating Activities
297
+ if sections[:operating]
298
+ puts "\nOPERATING ACTIVITIES"
299
+ puts "-" * 80
300
+ if sections[:operating][:items]
301
+ sections[:operating][:items].each do |item|
302
+ output_cash_flow_item(item, currency)
303
+ end
304
+ end
305
+ puts "-" * 80
306
+ puts "%-60s %20s" % ["Net Cash from Operating Activities", format_currency(sections[:operating][:total] || 0, currency)]
307
+ end
308
+
309
+ # Investing Activities
310
+ if sections[:investing]
311
+ puts "\nINVESTING ACTIVITIES"
312
+ puts "-" * 80
313
+ if sections[:investing][:items] && sections[:investing][:items].any?
314
+ sections[:investing][:items].each do |item|
315
+ output_cash_flow_item(item, currency)
316
+ end
317
+ end
318
+ puts "-" * 80
319
+ puts "%-60s %20s" % ["Net Cash from Investing Activities", format_currency(sections[:investing][:total] || 0, currency)]
320
+ end
321
+
322
+ # Financing Activities
323
+ if sections[:financing]
324
+ puts "\nFINANCING ACTIVITIES"
325
+ puts "-" * 80
326
+ if sections[:financing][:items] && sections[:financing][:items].any?
327
+ sections[:financing][:items].each do |item|
328
+ output_cash_flow_item(item, currency)
329
+ end
330
+ end
331
+ puts "-" * 80
332
+ puts "%-60s %20s" % ["Net Cash from Financing Activities", format_currency(sections[:financing][:total] || 0, currency)]
333
+ end
334
+
335
+ # Summary
336
+ puts "\n"
337
+ puts "=" * 80
338
+
339
+ if sections[:net_change_in_cash]
340
+ net_change = sections[:net_change_in_cash][:total] || sections[:net_change_in_cash][:value] || 0
341
+ puts "%-60s %20s" % ["NET CHANGE IN CASH", format_currency(net_change, currency)]
342
+ end
343
+
344
+ if sections[:beginning_cash]
345
+ beginning_cash = sections[:beginning_cash][:total] || sections[:beginning_cash][:value] || 0
346
+ puts "%-60s %20s" % ["Beginning Cash Balance", format_currency(beginning_cash, currency)]
347
+ end
348
+
349
+ if sections[:ending_cash]
350
+ ending_cash = sections[:ending_cash][:total] || sections[:ending_cash][:value] || 0
351
+ puts "%-60s %20s" % ["ENDING CASH BALANCE", format_currency(ending_cash, currency)]
352
+ end
353
+ end
354
+
355
+ def output_simple_cash_flow(data, currency)
356
+ sections = data[:sections] || {}
357
+
358
+ # Cash Inflows
359
+ if sections[:cash_in]
360
+ puts "\nCASH INFLOWS"
361
+ puts "-" * 80
362
+ if sections[:cash_in][:items]
363
+ sections[:cash_in][:items].each do |item|
364
+ output_cash_flow_item(item, currency)
365
+ end
366
+ end
367
+ puts "-" * 80
368
+ puts "%-60s %20s" % ["Total Cash Inflows", format_currency(sections[:cash_in][:total] || 0, currency)]
369
+ end
370
+
371
+ # Cash Outflows
372
+ if sections[:cash_out]
373
+ puts "\nCASH OUTFLOWS"
374
+ puts "-" * 80
375
+ if sections[:cash_out][:items]
376
+ sections[:cash_out][:items].each do |item|
377
+ output_cash_flow_item(item, currency)
378
+ end
379
+ end
380
+ puts "-" * 80
381
+ puts "%-60s %20s" % ["Total Cash Outflows", format_currency(sections[:cash_out][:total] || 0, currency)]
382
+ end
383
+
384
+ # Summary
385
+ puts "\n"
386
+ puts "=" * 80
387
+
388
+ if sections[:net_change_in_cash]
389
+ net_change = sections[:net_change_in_cash][:total] || sections[:net_change_in_cash][:value] || 0
390
+ puts "%-60s %20s" % ["NET CHANGE IN CASH", format_currency(net_change, currency)]
391
+ end
392
+
393
+ if sections[:beginning_cash]
394
+ beginning_cash = sections[:beginning_cash][:total] || sections[:beginning_cash][:value] || 0
395
+ puts "%-60s %20s" % ["Beginning Cash Balance", format_currency(beginning_cash, currency)]
396
+ end
397
+
398
+ if sections[:ending_cash]
399
+ ending_cash = sections[:ending_cash][:total] || sections[:ending_cash][:value] || 0
400
+ puts "%-60s %20s" % ["ENDING CASH BALANCE", format_currency(ending_cash, currency)]
401
+ end
402
+ end
403
+
404
+ def output_cash_flow_item(item, currency, indent = 0)
405
+ prefix = " " * indent
406
+ display_name = item[:display_name] || item[:name]
407
+ value = item[:value] || 0
408
+ puts "%-60s %20s" % ["#{prefix}#{display_name}", format_currency(value, currency)]
409
+ end
410
+
411
+ def output_balance_sheet_section(section, currency, section_name)
412
+ puts "\n#{section_name}"
413
+ puts "-" * 80
414
+
415
+ if section[:items]
416
+ section[:items].each do |item|
417
+ output_item_row(item, currency, 0, show_category_total: false)
418
+ end
419
+ end
420
+ end
421
+
422
+ def output_section(section, currency, section_name, show_total: false)
423
+ puts "\n#{section_name}"
424
+ puts "-" * 80
425
+
426
+ if section[:items]
427
+ section[:items].each do |item|
428
+ output_item_row(item, currency, show_category_total: false)
429
+ end
430
+ end
431
+
432
+ # Show section total only if requested (for top-level sections)
433
+ if show_total && section[:total]
434
+ puts "-" * 80
435
+ puts "%-60s %20s" % ["TOTAL #{section_name}", format_currency(section[:total], currency)]
436
+ end
437
+ end
438
+
439
+ def output_item_row(item, currency, indent = 0, show_category_total: false)
440
+ prefix = " " * indent
441
+ display_name = item[:display_name] || item[:name]
442
+
443
+ if item[:subcategories] && item[:subcategories].any?
444
+ # Parent category with children - show header only if no value at parent level
445
+ if item[:value] && item[:value] != 0
446
+ # Show parent with value, then children
447
+ puts "%-60s %20s" % ["#{prefix}#{display_name}", format_currency(item[:value], currency)]
448
+ item[:subcategories].each do |sub|
449
+ output_item_row(sub, currency, indent + 1, show_category_total: show_category_total)
450
+ end
451
+ else
452
+ # Show parent as header, then children
453
+ puts "#{prefix}#{display_name}:"
454
+ item[:subcategories].each do |sub|
455
+ output_item_row(sub, currency, indent + 1, show_category_total: show_category_total)
456
+ end
457
+ # Show subtotal only if explicitly requested (for nested categories)
458
+ if show_category_total && item[:value]
459
+ puts "%-60s %20s" % ["#{prefix} Subtotal", format_currency(item[:value], currency)]
460
+ end
461
+ end
462
+ else
463
+ # Leaf item
464
+ value_str = format_currency(item[:value], currency)
465
+ puts "%-60s %20s" % ["#{prefix}#{display_name}", value_str]
466
+ end
467
+ end
468
+
469
+ def output_item(item, currency, indent = 0)
470
+ # Legacy method for backward compatibility
471
+ output_item_row(item, currency, indent)
472
+ end
473
+
474
+ def format_currency(value, currency = nil)
475
+ currency ||= @report.output_currency
476
+
477
+ if value.is_a?(Money)
478
+ "#{currency_symbol(value.currency.iso_code)}#{format_number(value)}"
479
+ elsif value
480
+ "#{currency_symbol(currency)}#{format_number(value)}"
481
+ else
482
+ "#{currency_symbol(currency)}0"
483
+ end
484
+ end
485
+
486
+ def format_display_value(value, format_type, currency = nil)
487
+ currency ||= @report.output_currency
488
+
489
+ case format_type
490
+ when :percentage
491
+ "#{format_number(value)}%"
492
+ when :currency
493
+ format_currency(value, currency)
494
+ when :number
495
+ format_number(value)
496
+ else
497
+ format_number(value)
498
+ end
499
+ end
500
+
501
+ def format_number(value)
502
+ if value.is_a?(Money)
503
+ formatted = sprintf("%.2f", value.to_f)
504
+ elsif value.is_a?(Numeric)
505
+ # Always use 2 decimal places
506
+ formatted = sprintf("%.2f", value)
507
+ else
508
+ formatted = value.to_s
509
+ end
510
+
511
+ # Add thousands separator (commas)
512
+ if formatted.is_a?(String) && formatted =~ /^-?\d+\.\d+$/
513
+ # Split on decimal point
514
+ parts = formatted.split('.')
515
+ integer_part = parts[0]
516
+ decimal_part = parts[1]
517
+
518
+ # Handle negative sign
519
+ is_negative = integer_part.start_with?('-')
520
+ integer_part = integer_part.sub(/^-/, '') if is_negative
521
+
522
+ # Add commas to integer part
523
+ integer_part = integer_part.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
524
+
525
+ # Restore negative sign if needed
526
+ integer_part = "-#{integer_part}" if is_negative
527
+
528
+ formatted = "#{integer_part}.#{decimal_part}"
529
+ end
530
+
531
+ formatted
532
+ end
533
+
534
+ def currency_symbol(currency_code)
535
+ case currency_code.to_s.upcase
536
+ when 'USD'
537
+ '$'
538
+ when 'EUR'
539
+ '€'
540
+ when 'GBP'
541
+ '£'
542
+ when 'JPY'
543
+ '¥'
544
+ else
545
+ "#{currency_code} "
546
+ end
547
+ end
548
+
549
+ def output_statement_comments(comments)
550
+ puts "\n" + "=" * 80
551
+ puts "STATEMENT NOTES"
552
+ puts "=" * 80
553
+
554
+ comments.each do |category_name, comment|
555
+ puts "• #{humanize_category_name(category_name)}: #{comment}"
556
+ end
557
+
558
+ puts "=" * 80
559
+ end
560
+
561
+ def humanize_category_name(name)
562
+ name.to_s.split('_').map(&:capitalize).join(' ')
563
+ end
564
+
565
+ def output_monthly_breakdown(data, currency)
566
+ breakdown_mode = options[:monthly_breakdown] || :detailed
567
+
568
+ puts "=" * 120
569
+ puts "MONTHLY BREAKDOWN (#{breakdown_mode.to_s.upcase})"
570
+ puts "=" * 120
571
+
572
+ if breakdown_mode == :average
573
+ output_average_monthly(data, currency)
574
+ else
575
+ output_detailed_monthly(data, currency)
576
+ end
577
+ end
578
+
579
+ def output_average_monthly(data, currency)
580
+ start_date = @report.start_date
581
+ end_date = @report.end_date
582
+
583
+ # Calculate number of months
584
+ months_count = ((end_date.year - start_date.year) * 12 + end_date.month - start_date.month + 1)
585
+
586
+ puts "Average Month (calculated over #{months_count} months: #{start_date.strftime('%b %Y')} - #{end_date.strftime('%b %Y')})"
587
+ puts "=" * 120
588
+ puts ""
589
+
590
+ # Calculate averages
591
+ puts "%-40s %20s" % ["Item", "Avg Month (#{currency})"]
592
+ puts "-" * 120
593
+
594
+ # Income section
595
+ if data[:sections][:income]
596
+ output_monthly_section(data[:sections][:income], currency, months_count, "INCOME")
597
+ end
598
+
599
+ puts ""
600
+
601
+ # Expense section
602
+ if data[:sections][:expenses]
603
+ output_monthly_section(data[:sections][:expenses], currency, months_count, "EXPENSES")
604
+ end
605
+
606
+ puts ""
607
+ puts "-" * 120
608
+
609
+ # Net Income
610
+ total_income = data[:sections][:income][:total] || 0
611
+ total_expenses = data[:sections][:expenses][:total] || 0
612
+ avg_net_income = (total_income - total_expenses) / months_count.to_f
613
+
614
+ puts "%-40s %20s" % ["NET INCOME", format_currency(avg_net_income, currency)]
615
+ puts "=" * 120
616
+ end
617
+
618
+ def output_detailed_monthly(data, currency)
619
+ start_date = @report.start_date
620
+ end_date = @report.end_date
621
+
622
+ # Generate list of months
623
+ months = []
624
+ current_date = start_date
625
+ while current_date <= end_date
626
+ months << current_date.strftime("%b %Y")
627
+ current_date = current_date >> 1
628
+ end
629
+
630
+ # Determine column width based on number of months
631
+ col_width = [15, 120 / (months.length + 1)].max
632
+ total_width = 40 + (col_width * months.length)
633
+
634
+ # Headers
635
+ header_format = "%-40s" + (" %#{col_width-1}s" * months.length)
636
+ puts header_format % (["Item"] + months)
637
+ puts "-" * total_width
638
+
639
+ # Income section
640
+ if data[:sections][:income]
641
+ puts "\n#{data[:sections][:income][:name]}"
642
+ puts "-" * total_width
643
+ output_detailed_section_items(data[:sections][:income], months.length, col_width)
644
+
645
+ # Total income
646
+ income_total = data[:sections][:income][:total] || 0
647
+ monthly_income = income_total / 12.0
648
+ values = ["TOTAL INCOME"] + [format_currency(monthly_income, currency)] * months.length
649
+ puts (header_format % values)
650
+ end
651
+
652
+ puts ""
653
+
654
+ # Expense section
655
+ if data[:sections][:expenses]
656
+ puts "\n#{data[:sections][:expenses][:name]}"
657
+ puts "-" * total_width
658
+ output_detailed_section_items(data[:sections][:expenses], months.length, col_width)
659
+
660
+ # Total expenses
661
+ expense_total = data[:sections][:expenses][:total] || 0
662
+ monthly_expense = expense_total / 12.0
663
+ values = ["TOTAL EXPENSES"] + [format_currency(monthly_expense, currency)] * months.length
664
+ puts (header_format % values)
665
+ end
666
+
667
+ puts ""
668
+ puts "-" * total_width
669
+
670
+ # Net Income
671
+ total_income = data[:sections][:income][:total] || 0
672
+ total_expenses = data[:sections][:expenses][:total] || 0
673
+ monthly_net = (total_income - total_expenses) / 12.0
674
+ values = ["NET INCOME"] + [format_currency(monthly_net, currency)] * months.length
675
+ puts (header_format % values)
676
+ puts "=" * 120
677
+ end
678
+
679
+ def output_monthly_section(section, currency, months_count, label)
680
+ puts "\n#{label}"
681
+ puts "-" * 120
682
+
683
+ if section[:items]
684
+ section[:items].each do |item|
685
+ output_monthly_item(item, currency, months_count)
686
+ end
687
+ end
688
+
689
+ avg_total = section[:total] ? section[:total] / months_count.to_f : 0
690
+ puts "%-40s %20s" % ["Total #{section[:name]}", format_currency(avg_total, currency)]
691
+ end
692
+
693
+ def output_monthly_item(item, currency, months_count, indent = 0)
694
+ prefix = " " * indent
695
+ display_name = item[:display_name] || item[:name]
696
+
697
+ if item[:subcategories]
698
+ puts "%-40s" % ["#{prefix}#{display_name}:"]
699
+ item[:subcategories].each { |sub| output_monthly_item(sub, currency, months_count, indent + 1) }
700
+ else
701
+ avg_value = item[:value] ? item[:value] / months_count.to_f : 0
702
+ puts "%-40s %20s" % ["#{prefix}#{display_name}", format_currency(avg_value, currency)]
703
+ end
704
+ end
705
+
706
+ def output_detailed_section_items(section, months_count, col_width, start_date = nil)
707
+ currency = @report.output_currency
708
+ header_format = "%-40s" + (" %#{col_width-1}s" * months_count)
709
+ start_date ||= @report.start_date
710
+
711
+ if section[:items]
712
+ section[:items].each do |item|
713
+ output_detailed_item_row(item, months_count, col_width, header_format, currency, start_date)
714
+ end
715
+ end
716
+ end
717
+
718
+ def output_detailed_item_row(item, months_count, col_width, format_str, currency, start_date = nil, indent = 0)
719
+ prefix = " " * indent
720
+ display_name = item[:display_name] || item[:name]
721
+ start_date ||= @report.start_date
722
+
723
+ if item[:subcategories]
724
+ # Parent category
725
+ puts "#{prefix}#{display_name}:"
726
+ item[:subcategories].each do |sub|
727
+ output_detailed_item_row(sub, months_count, col_width, format_str, currency, start_date, indent + 1)
728
+ end
729
+ else
730
+ # Leaf item - use report data or model methods
731
+ # For monthly breakdown, we should use the report's monthly data if available
732
+ # Otherwise, fall back to dividing annual value by 12
733
+ monthly_values = []
734
+ current_date = start_date
735
+
736
+ months_count.times do
737
+ # Try to get monthly value from report data structure
738
+ # If not available, divide annual value by 12
739
+ month_value = if item[:value]
740
+ item[:value] / 12.0
741
+ else
742
+ 0
743
+ end
744
+
745
+ monthly_values << format_currency(month_value, currency)
746
+ current_date = current_date >> 1 # Next month
747
+ end
748
+
749
+ values = ["#{prefix}#{display_name}"] + monthly_values
750
+ puts (format_str % values)
751
+ end
752
+ end
753
+
754
+ def output_income_statement_periods_columns(periods, header_format, currency, col_width)
755
+ # Check if we're in separate_cogs mode by looking at first period
756
+ first_period_data = periods.first
757
+ first_report_data = first_period_data[:report] || first_period_data
758
+ separate_cogs_mode = first_report_data[:expense_display_mode] == :separate_cogs
759
+
760
+ # Collect display items from first period
761
+ display_items = first_report_data[:display_items] || []
762
+
763
+ # Collect all unique items across periods
764
+ income_items = {}
765
+ cogs_items = {}
766
+ expense_items = {}
767
+
768
+ periods.each_with_index do |period_data, idx|
769
+ report_data = period_data[:report] || period_data
770
+ sections = report_data[:sections] || {}
771
+
772
+ # Income items
773
+ if sections[:income] && sections[:income][:items]
774
+ sections[:income][:items].each do |item|
775
+ collect_items_for_periods(item, income_items, idx, currency, :income, nil)
776
+ end
777
+ end
778
+
779
+ # COGS items (if separate_cogs mode)
780
+ if separate_cogs_mode && sections[:cogs] && sections[:cogs][:items]
781
+ sections[:cogs][:items].each do |item|
782
+ collect_items_for_periods(item, cogs_items, idx, currency, :cogs, nil)
783
+ end
784
+ end
785
+
786
+ # Expense items (operating expenses in separate_cogs mode, all expenses otherwise)
787
+ if sections[:expenses] && sections[:expenses][:items]
788
+ sections[:expenses][:items].each do |item|
789
+ collect_items_for_periods(item, expense_items, idx, currency, :expense, nil)
790
+ end
791
+ end
792
+ end
793
+
794
+ # Output display items before income section
795
+ output_display_items_periods(display_items, :before, :income, periods, header_format, currency)
796
+
797
+ # Output income section
798
+ puts "\nINCOME"
799
+ puts "-" * (40 + col_width * periods.length)
800
+ output_items_periods_columns(income_items, periods, header_format, currency, col_width)
801
+
802
+ # Output income total
803
+ puts "-" * (40 + col_width * periods.length)
804
+ income_total_row = ["TOTAL INCOME"]
805
+ periods.each do |period_data|
806
+ report_data = period_data[:report] || period_data
807
+ sections = report_data[:sections] || {}
808
+ income_total = sections[:income] ? (sections[:income][:total] || 0) : 0
809
+ income_total_row << format_currency(income_total, currency)
810
+ end
811
+ puts header_format % income_total_row
812
+
813
+ # Output display items after income section
814
+ output_display_items_periods(display_items, :after, :income, periods, header_format, currency)
815
+
816
+ # Output COGS section (if separate_cogs mode)
817
+ if separate_cogs_mode && cogs_items.any?
818
+ output_display_items_periods(display_items, :before, :cogs, periods, header_format, currency)
819
+ puts "\nCOST OF GOODS SOLD"
820
+ puts "-" * (40 + col_width * periods.length)
821
+ output_items_periods_columns(cogs_items, periods, header_format, currency, col_width)
822
+
823
+ # Output COGS total
824
+ puts "-" * (40 + col_width * periods.length)
825
+ cogs_total_row = ["TOTAL COST OF GOODS SOLD"]
826
+ periods.each do |period_data|
827
+ report_data = period_data[:report] || period_data
828
+ sections = report_data[:sections] || {}
829
+ cogs_total = sections[:cogs] ? (sections[:cogs][:total] || 0) : 0
830
+ cogs_total_row << format_currency(cogs_total, currency)
831
+ end
832
+ puts header_format % cogs_total_row
833
+
834
+ # Output Gross Margin
835
+ puts "-" * (40 + col_width * periods.length)
836
+ gross_margin_row = ["GROSS MARGIN"]
837
+ periods.each do |period_data|
838
+ report_data = period_data[:report] || period_data
839
+ sections = report_data[:sections] || {}
840
+ if sections[:gross_margin]
841
+ gross_margin = sections[:gross_margin][:total] || sections[:gross_margin][:value] || 0
842
+ gross_margin_row << format_currency(gross_margin, currency)
843
+ else
844
+ gross_margin_row << format_currency(0, currency)
845
+ end
846
+ end
847
+ puts header_format % gross_margin_row
848
+
849
+ # Output display items after gross margin
850
+ output_display_items_periods(display_items, :after, :gross_margin, periods, header_format, currency)
851
+ # Output display items in column next to gross margin
852
+ output_display_items_periods_column(display_items, :gross_margin, periods, header_format, currency)
853
+ end
854
+
855
+ # Output expense section
856
+ output_display_items_periods(display_items, :before, :expenses, periods, header_format, currency)
857
+ section_label = separate_cogs_mode ? "OPERATING EXPENSES" : "EXPENSES"
858
+ puts "\n#{section_label}"
859
+ puts "-" * (40 + col_width * periods.length)
860
+ output_items_periods_columns(expense_items, periods, header_format, currency, col_width)
861
+
862
+ # Output expense total
863
+ puts "-" * (40 + col_width * periods.length)
864
+ expense_total_label = separate_cogs_mode ? "TOTAL OPERATING EXPENSES" : "TOTAL EXPENSES"
865
+ expense_total_row = [expense_total_label]
866
+ periods.each do |period_data|
867
+ report_data = period_data[:report] || period_data
868
+ sections = report_data[:sections] || {}
869
+ expense_total = sections[:expenses] ? (sections[:expenses][:total] || 0) : 0
870
+ expense_total_row << format_currency(expense_total, currency)
871
+ end
872
+ puts header_format % expense_total_row
873
+ output_display_items_periods(display_items, :after, :expenses, periods, header_format, currency)
874
+
875
+ # Output totals row
876
+ output_display_items_periods(display_items, :before, :net_income, periods, header_format, currency)
877
+ puts "-" * (40 + col_width * periods.length)
878
+ totals_row = ["NET INCOME"]
879
+ periods.each do |period_data|
880
+ report_data = period_data[:report] || period_data
881
+ totals = report_data[:totals] || {}
882
+ income = totals[:income] || 0
883
+ expenses = totals[:expenses] || 0
884
+ net_income = income - expenses
885
+ totals_row << format_currency(net_income, currency)
886
+ end
887
+ puts header_format % totals_row
888
+ output_display_items_periods(display_items, :after, :net_income, periods, header_format, currency)
889
+ end
890
+
891
+ def output_display_items_periods(display_items, position_type, section_key, periods, header_format, currency)
892
+ items = display_items.select { |item| item[position_type] == section_key }
893
+ return if items.empty?
894
+
895
+ # Sort by order
896
+ items = items.sort_by { |item| item[:order] || 0 }
897
+
898
+ items.each do |item|
899
+ row = [item[:label]]
900
+
901
+ periods.each_with_index do |period_data, idx|
902
+ report_data = period_data[:report] || period_data
903
+ period_display_items = report_data[:display_items] || []
904
+
905
+ # Find this item in the period's display items
906
+ period_item = period_display_items.find { |di| di[:name] == item[:name] }
907
+
908
+ if period_item
909
+ value_str = format_display_value(period_item[:value], period_item[:format], currency)
910
+ row << value_str
911
+ else
912
+ row << format_display_value(0, item[:format], currency)
913
+ end
914
+ end
915
+
916
+ puts header_format % row
917
+ end
918
+ end
919
+
920
+ def output_display_items_periods_column(display_items, section_key, periods, header_format, currency)
921
+ items = display_items.select { |item| item[:column] == section_key }
922
+ return if items.empty?
923
+
924
+ # Sort by order
925
+ items = items.sort_by { |item| item[:order] || 0 }
926
+
927
+ items.each do |item|
928
+ row = [" #{item[:label]}"]
929
+
930
+ periods.each_with_index do |period_data, idx|
931
+ report_data = period_data[:report] || period_data
932
+ period_display_items = report_data[:display_items] || []
933
+
934
+ # Find this item in the period's display items
935
+ period_item = period_display_items.find { |di| di[:name] == item[:name] }
936
+
937
+ if item[:last_column_only]
938
+ # Only show in last column
939
+ if idx == periods.length - 1 && period_item
940
+ value_str = format_display_value(period_item[:value], period_item[:format], currency)
941
+ row << value_str
942
+ else
943
+ row << ""
944
+ end
945
+ else
946
+ # Show in all columns
947
+ if period_item
948
+ value_str = format_display_value(period_item[:value], period_item[:format], currency)
949
+ row << value_str
950
+ else
951
+ row << format_display_value(0, item[:format], currency)
952
+ end
953
+ end
954
+ end
955
+
956
+ puts header_format % row
957
+ end
958
+ end
959
+
960
+ def output_balance_sheet_periods_columns(periods, header_format, currency, col_width)
961
+ # Collect all unique items across periods
962
+ all_items = {}
963
+
964
+ periods.each_with_index do |period_data, idx|
965
+ report_data = period_data[:report] || period_data
966
+ sections = report_data[:sections] || {}
967
+
968
+ # Asset items
969
+ if sections[:assets] && sections[:assets][:items]
970
+ sections[:assets][:items].each do |item|
971
+ collect_items_for_periods(item, all_items, idx, currency, :asset, nil)
972
+ end
973
+ end
974
+
975
+ # Liability items
976
+ if sections[:liabilities] && sections[:liabilities][:items]
977
+ sections[:liabilities][:items].each do |item|
978
+ collect_items_for_periods(item, all_items, idx, currency, :liability, nil)
979
+ end
980
+ end
981
+
982
+ # Equity items
983
+ if sections[:equity] && sections[:equity][:items]
984
+ sections[:equity][:items].each do |item|
985
+ collect_items_for_periods(item, all_items, idx, currency, :equity, nil)
986
+ end
987
+ end
988
+
989
+ # Accumulated Net Income items
990
+ if sections[:accumulated_net_income] && sections[:accumulated_net_income][:items]
991
+ sections[:accumulated_net_income][:items].each do |item|
992
+ collect_items_for_periods(item, all_items, idx, currency, :accumulated_net_income, nil)
993
+ end
994
+ end
995
+ end
996
+
997
+ # Output assets section
998
+ puts "\nASSETS"
999
+ puts "-" * (40 + col_width * periods.length)
1000
+ output_items_periods_columns(all_items.select { |k, v| v[:type] == :asset }, periods, header_format, currency, col_width)
1001
+
1002
+ # Output liabilities section
1003
+ puts "\nLIABILITIES"
1004
+ puts "-" * (40 + col_width * periods.length)
1005
+ output_items_periods_columns(all_items.select { |k, v| v[:type] == :liability }, periods, header_format, currency, col_width)
1006
+
1007
+ # Output equity section
1008
+ puts "\nEQUITY"
1009
+ puts "-" * (40 + col_width * periods.length)
1010
+ output_items_periods_columns(all_items.select { |k, v| v[:type] == :equity }, periods, header_format, currency, col_width)
1011
+
1012
+ # Output accumulated net income section
1013
+ accumulated_net_income_items = all_items.select { |k, v| v[:type] == :accumulated_net_income }
1014
+ if accumulated_net_income_items.any?
1015
+ puts "\nACCUMULATED NET INCOME"
1016
+ puts "-" * (40 + col_width * periods.length)
1017
+ output_items_periods_columns(accumulated_net_income_items, periods, header_format, currency, col_width)
1018
+ end
1019
+ end
1020
+
1021
+ def output_cash_flow_statement_periods_columns(periods, header_format, currency, col_width)
1022
+ # Check display mode from first period
1023
+ first_period_data = periods.first
1024
+ first_report_data = first_period_data[:report] || first_period_data
1025
+ display_mode = first_report_data[:display_mode] || :standard
1026
+
1027
+ if display_mode == :simple
1028
+ output_simple_cash_flow_periods_columns(periods, header_format, currency, col_width)
1029
+ else
1030
+ output_standard_cash_flow_periods_columns(periods, header_format, currency, col_width)
1031
+ end
1032
+ end
1033
+
1034
+ def output_standard_cash_flow_periods_columns(periods, header_format, currency, col_width)
1035
+ line_width = 40 + col_width * periods.length
1036
+
1037
+ # Operating Activities
1038
+ puts "\nOPERATING ACTIVITIES"
1039
+ puts "-" * line_width
1040
+ output_cash_flow_section_items_periods(:operating, periods, header_format, currency)
1041
+ puts "-" * line_width
1042
+ operating_row = ["Net Cash from Operating Activities"]
1043
+ periods.each do |period_data|
1044
+ report_data = period_data[:report] || period_data
1045
+ sections = report_data[:sections] || {}
1046
+ operating_total = sections[:operating] ? (sections[:operating][:total] || 0) : 0
1047
+ operating_row << format_currency(operating_total, currency)
1048
+ end
1049
+ puts header_format % operating_row
1050
+
1051
+ # Investing Activities
1052
+ puts "\nINVESTING ACTIVITIES"
1053
+ puts "-" * line_width
1054
+ output_cash_flow_section_items_periods(:investing, periods, header_format, currency)
1055
+ puts "-" * line_width
1056
+ investing_row = ["Net Cash from Investing Activities"]
1057
+ periods.each do |period_data|
1058
+ report_data = period_data[:report] || period_data
1059
+ sections = report_data[:sections] || {}
1060
+ investing_total = sections[:investing] ? (sections[:investing][:total] || 0) : 0
1061
+ investing_row << format_currency(investing_total, currency)
1062
+ end
1063
+ puts header_format % investing_row
1064
+
1065
+ # Financing Activities
1066
+ puts "\nFINANCING ACTIVITIES"
1067
+ puts "-" * line_width
1068
+ output_cash_flow_section_items_periods(:financing, periods, header_format, currency)
1069
+ puts "-" * line_width
1070
+ financing_row = ["Net Cash from Financing Activities"]
1071
+ periods.each do |period_data|
1072
+ report_data = period_data[:report] || period_data
1073
+ sections = report_data[:sections] || {}
1074
+ financing_total = sections[:financing] ? (sections[:financing][:total] || 0) : 0
1075
+ financing_row << format_currency(financing_total, currency)
1076
+ end
1077
+ puts header_format % financing_row
1078
+
1079
+ # Summary section
1080
+ puts "\n"
1081
+ puts "-" * line_width
1082
+
1083
+ # Net Change in Cash
1084
+ net_change_row = ["NET CHANGE IN CASH"]
1085
+ periods.each do |period_data|
1086
+ report_data = period_data[:report] || period_data
1087
+ sections = report_data[:sections] || {}
1088
+ net_change = sections[:net_change_in_cash] ? (sections[:net_change_in_cash][:total] || sections[:net_change_in_cash][:value] || 0) : 0
1089
+ net_change_row << format_currency(net_change, currency)
1090
+ end
1091
+ puts header_format % net_change_row
1092
+
1093
+ # Beginning Cash Balance
1094
+ beginning_row = ["Beginning Cash Balance"]
1095
+ periods.each do |period_data|
1096
+ report_data = period_data[:report] || period_data
1097
+ sections = report_data[:sections] || {}
1098
+ beginning_cash = sections[:beginning_cash] ? (sections[:beginning_cash][:total] || sections[:beginning_cash][:value] || 0) : 0
1099
+ beginning_row << format_currency(beginning_cash, currency)
1100
+ end
1101
+ puts header_format % beginning_row
1102
+
1103
+ # Ending Cash Balance
1104
+ ending_row = ["ENDING CASH BALANCE"]
1105
+ periods.each do |period_data|
1106
+ report_data = period_data[:report] || period_data
1107
+ sections = report_data[:sections] || {}
1108
+ ending_cash = sections[:ending_cash] ? (sections[:ending_cash][:total] || sections[:ending_cash][:value] || 0) : 0
1109
+ ending_row << format_currency(ending_cash, currency)
1110
+ end
1111
+ puts header_format % ending_row
1112
+ end
1113
+
1114
+ def output_simple_cash_flow_periods_columns(periods, header_format, currency, col_width)
1115
+ line_width = 40 + col_width * periods.length
1116
+
1117
+ # Cash Inflows
1118
+ puts "\nCASH INFLOWS"
1119
+ puts "-" * line_width
1120
+ output_cash_flow_section_items_periods(:cash_in, periods, header_format, currency)
1121
+ puts "-" * line_width
1122
+ cash_in_row = ["Total Cash Inflows"]
1123
+ periods.each do |period_data|
1124
+ report_data = period_data[:report] || period_data
1125
+ sections = report_data[:sections] || {}
1126
+ cash_in_total = sections[:cash_in] ? (sections[:cash_in][:total] || 0) : 0
1127
+ cash_in_row << format_currency(cash_in_total, currency)
1128
+ end
1129
+ puts header_format % cash_in_row
1130
+
1131
+ # Cash Outflows
1132
+ puts "\nCASH OUTFLOWS"
1133
+ puts "-" * line_width
1134
+ output_cash_flow_section_items_periods(:cash_out, periods, header_format, currency)
1135
+ puts "-" * line_width
1136
+ cash_out_row = ["Total Cash Outflows"]
1137
+ periods.each do |period_data|
1138
+ report_data = period_data[:report] || period_data
1139
+ sections = report_data[:sections] || {}
1140
+ cash_out_total = sections[:cash_out] ? (sections[:cash_out][:total] || 0) : 0
1141
+ cash_out_row << format_currency(cash_out_total, currency)
1142
+ end
1143
+ puts header_format % cash_out_row
1144
+
1145
+ # Summary section
1146
+ puts "\n"
1147
+ puts "-" * line_width
1148
+
1149
+ # Net Change in Cash
1150
+ net_change_row = ["NET CHANGE IN CASH"]
1151
+ periods.each do |period_data|
1152
+ report_data = period_data[:report] || period_data
1153
+ sections = report_data[:sections] || {}
1154
+ net_change = sections[:net_change_in_cash] ? (sections[:net_change_in_cash][:total] || sections[:net_change_in_cash][:value] || 0) : 0
1155
+ net_change_row << format_currency(net_change, currency)
1156
+ end
1157
+ puts header_format % net_change_row
1158
+
1159
+ # Beginning Cash Balance
1160
+ beginning_row = ["Beginning Cash Balance"]
1161
+ periods.each do |period_data|
1162
+ report_data = period_data[:report] || period_data
1163
+ sections = report_data[:sections] || {}
1164
+ beginning_cash = sections[:beginning_cash] ? (sections[:beginning_cash][:total] || sections[:beginning_cash][:value] || 0) : 0
1165
+ beginning_row << format_currency(beginning_cash, currency)
1166
+ end
1167
+ puts header_format % beginning_row
1168
+
1169
+ # Ending Cash Balance
1170
+ ending_row = ["ENDING CASH BALANCE"]
1171
+ periods.each do |period_data|
1172
+ report_data = period_data[:report] || period_data
1173
+ sections = report_data[:sections] || {}
1174
+ ending_cash = sections[:ending_cash] ? (sections[:ending_cash][:total] || sections[:ending_cash][:value] || 0) : 0
1175
+ ending_row << format_currency(ending_cash, currency)
1176
+ end
1177
+ puts header_format % ending_row
1178
+ end
1179
+
1180
+ def output_cash_flow_section_items_periods(section_key, periods, header_format, currency)
1181
+ # Collect all unique items across periods for this section
1182
+ all_items = {}
1183
+
1184
+ periods.each_with_index do |period_data, idx|
1185
+ report_data = period_data[:report] || period_data
1186
+ sections = report_data[:sections] || {}
1187
+
1188
+ if sections[section_key] && sections[section_key][:items]
1189
+ sections[section_key][:items].each do |item|
1190
+ item_name = item[:name] || item[:display_name]
1191
+ unless all_items[item_name]
1192
+ all_items[item_name] = {
1193
+ name: item[:display_name] || item[:name],
1194
+ values: []
1195
+ }
1196
+ end
1197
+
1198
+ # Ensure values array is long enough
1199
+ while all_items[item_name][:values].length < idx
1200
+ all_items[item_name][:values] << 0
1201
+ end
1202
+
1203
+ all_items[item_name][:values][idx] = item[:value] || 0
1204
+ end
1205
+ end
1206
+ end
1207
+
1208
+ # Output each item
1209
+ all_items.each do |_key, item_data|
1210
+ row = [" #{item_data[:name]}"]
1211
+
1212
+ # Ensure we have values for all periods
1213
+ while item_data[:values].length < periods.length
1214
+ item_data[:values] << 0
1215
+ end
1216
+
1217
+ item_data[:values].each do |val|
1218
+ row << format_currency(val || 0, currency)
1219
+ end
1220
+
1221
+ puts header_format % row
1222
+ end
1223
+ end
1224
+
1225
+ def collect_items_for_periods(item, all_items, period_idx, currency, type = nil, parent_key = nil)
1226
+ key = item[:name] || item[:display_name]
1227
+ type ||= :income # default for income statements
1228
+
1229
+ # Initialize if first time seeing this item
1230
+ unless all_items[key]
1231
+ all_items[key] = {
1232
+ name: item[:display_name] || item[:name],
1233
+ indent: item[:indent] || 0,
1234
+ type: type,
1235
+ values: [],
1236
+ parent: parent_key,
1237
+ children: []
1238
+ }
1239
+ end
1240
+
1241
+ # Track parent-child relationship
1242
+ if parent_key && !all_items[parent_key][:children].include?(key)
1243
+ all_items[parent_key][:children] << key
1244
+ end
1245
+
1246
+ # Ensure values array is long enough
1247
+ while all_items[key][:values].length < period_idx
1248
+ all_items[key][:values] << nil
1249
+ end
1250
+
1251
+ # Set value for this period
1252
+ value = item[:value] || 0
1253
+ all_items[key][:values][period_idx] = value
1254
+
1255
+ # Handle subcategories recursively
1256
+ if item[:subcategories] && item[:subcategories].any?
1257
+ item[:subcategories].each do |sub|
1258
+ collect_items_for_periods(sub, all_items, period_idx, currency, type, key)
1259
+ end
1260
+ end
1261
+ end
1262
+
1263
+ def output_items_periods_columns(items_hash, periods, header_format, currency, col_width)
1264
+ # Find root items (items without parents)
1265
+ root_items = items_hash.select { |k, v| v[:parent].nil? }.keys
1266
+
1267
+ # Output items in hierarchical order with recursion protection
1268
+ visited = Set.new
1269
+ root_items.each do |root_key|
1270
+ output_item_hierarchical(root_key, items_hash, periods, header_format, currency, col_width, visited, 0)
1271
+ end
1272
+ end
1273
+
1274
+ def output_item_hierarchical(item_key, items_hash, periods, header_format, currency, col_width, visited = Set.new, depth = 0)
1275
+ # Protect against infinite recursion
1276
+ return if visited.include?(item_key)
1277
+ return if depth > 50 # Maximum depth limit
1278
+
1279
+ item_data = items_hash[item_key]
1280
+ return unless item_data
1281
+
1282
+ # Mark as visited
1283
+ visited.add(item_key)
1284
+
1285
+ # Output this item
1286
+ prefix = " " * (item_data[:indent] || 0)
1287
+ values = ["#{prefix}#{item_data[:name]}"]
1288
+
1289
+ # Ensure we have values for all periods
1290
+ while item_data[:values].length < periods.length
1291
+ item_data[:values] << nil
1292
+ end
1293
+
1294
+ # All values are stored as positive
1295
+ item_data[:values].each do |val|
1296
+ display_val = val || 0
1297
+ values << format_currency(display_val, currency)
1298
+ end
1299
+
1300
+ puts header_format % values
1301
+
1302
+ # Output children items recursively
1303
+ if item_data[:children] && item_data[:children].any?
1304
+ item_data[:children].each do |child_key|
1305
+ output_item_hierarchical(child_key, items_hash, periods, header_format, currency, col_width, visited, depth + 1)
1306
+ end
1307
+ end
1308
+
1309
+ # Remove from visited set after processing children (allows re-visiting in different branches)
1310
+ visited.delete(item_key)
1311
+ end
1312
+
1313
+ def output_scenario_comparison(data, currency)
1314
+ metadata = data[:metadata]
1315
+ base_metrics = data[:base]
1316
+ scenarios = data[:scenarios]
1317
+ variances = data[:variances]
1318
+ scenario_names = metadata[:scenario_names]
1319
+
1320
+ # Use normalized_metrics if available (has labels)
1321
+ normalized_metrics = metadata[:normalized_metrics] || []
1322
+
1323
+ # Calculate column widths
1324
+ label_width = 30
1325
+ value_width = 18
1326
+ num_scenarios = scenario_names.length
1327
+ total_width = label_width + value_width + (value_width * num_scenarios)
1328
+
1329
+ # Header
1330
+ puts "=" * total_width
1331
+ puts "SCENARIO COMPARISON (#{currency})"
1332
+ puts "Period: #{metadata[:start_date]} to #{metadata[:end_date]}"
1333
+ puts "=" * total_width
1334
+ puts ""
1335
+
1336
+ # Column headers
1337
+ header_format = "%-#{label_width}s %#{value_width}s" + (" %#{value_width}s" * num_scenarios)
1338
+ headers = ["Metric", "Base"] + scenario_names.map { |n| humanize_category_name(n) }
1339
+ puts header_format % headers
1340
+ puts "-" * total_width
1341
+
1342
+ # Output each metric using normalized metrics for labels
1343
+ normalized_metrics.each do |metric_config|
1344
+ metric_key = metric_config[:key]
1345
+ metric_label = metric_config[:label]
1346
+ base_value = base_metrics[metric_key] || 0
1347
+
1348
+ # First row: values
1349
+ values_row = [metric_label, format_currency(base_value, currency)]
1350
+ scenario_names.each do |scenario_name|
1351
+ scenario_metrics = scenarios[scenario_name]
1352
+ scenario_value = scenario_metrics ? (scenario_metrics[metric_key] || 0) : 0
1353
+ values_row << format_currency(scenario_value, currency)
1354
+ end
1355
+ puts header_format % values_row
1356
+
1357
+ # Second row: variance percentages (indented, under scenario columns)
1358
+ variance_row = ["", ""] # Empty for label and base columns
1359
+ scenario_names.each do |scenario_name|
1360
+ if variances[scenario_name]
1361
+ metric_variance = variances[scenario_name][metric_key]
1362
+ if metric_variance
1363
+ pct = metric_variance[:percentage]
1364
+ variance_str = pct >= 0 ? "+#{pct}%" : "#{pct}%"
1365
+ variance_row << variance_str
1366
+ else
1367
+ variance_row << ""
1368
+ end
1369
+ else
1370
+ variance_row << ""
1371
+ end
1372
+ end
1373
+ puts header_format % variance_row
1374
+
1375
+ puts "-" * total_width
1376
+ end
1377
+
1378
+ puts "=" * total_width
1379
+ puts ""
1380
+ puts "Generated at: #{metadata[:generated_at]}"
1381
+ end
1382
+
1383
+ def output_period_comparison(data, currency)
1384
+ metadata = data[:metadata]
1385
+ periods = data[:periods]
1386
+ variances = data[:variances]
1387
+ normalized_metrics = metadata[:normalized_metrics] || []
1388
+
1389
+ # Calculate column widths
1390
+ label_width = 30
1391
+ value_width = 18
1392
+ num_periods = periods.length
1393
+ total_width = label_width + (value_width * num_periods)
1394
+
1395
+ # Header
1396
+ puts "=" * total_width
1397
+ puts "PERIOD COMPARISON (#{currency})"
1398
+ puts "=" * total_width
1399
+ puts ""
1400
+
1401
+ # Column headers
1402
+ header_format = "%-#{label_width}s" + (" %#{value_width}s" * num_periods)
1403
+ headers = ["Metric"] + periods.map { |p| p[:name] }
1404
+ puts header_format % headers
1405
+ puts "-" * total_width
1406
+
1407
+ # Output each metric
1408
+ normalized_metrics.each do |metric_config|
1409
+ metric_key = metric_config[:variable]
1410
+ metric_label = metric_config[:label]
1411
+
1412
+ # First row: values
1413
+ values_row = [metric_label]
1414
+ periods.each do |period|
1415
+ period_value = period[:metrics][metric_key] || 0
1416
+ values_row << format_currency(period_value, currency)
1417
+ end
1418
+ puts header_format % values_row
1419
+
1420
+ # Second row: variance percentages (first period is blank)
1421
+ variance_row = [""]
1422
+ variances.each_with_index do |var_data, idx|
1423
+ if var_data[:variances]
1424
+ metric_variance = var_data[:variances][metric_key]
1425
+ if metric_variance
1426
+ pct = metric_variance[:percentage]
1427
+ variance_str = pct >= 0 ? "+#{pct}%" : "#{pct}%"
1428
+ variance_row << variance_str
1429
+ else
1430
+ variance_row << ""
1431
+ end
1432
+ else
1433
+ variance_row << "" # First period has no variance
1434
+ end
1435
+ end
1436
+ puts header_format % variance_row
1437
+
1438
+ puts "-" * total_width
1439
+ end
1440
+
1441
+ puts "=" * total_width
1442
+ puts ""
1443
+ puts "Generated at: #{metadata[:generated_at]}"
1444
+ end
1445
+
1446
+ def output_custom_sheet(data)
1447
+ title = data[:title]
1448
+ sections = data[:sections]
1449
+
1450
+ puts "=" * 80
1451
+ puts title.upcase.center(80)
1452
+ puts "=" * 80
1453
+ puts ""
1454
+
1455
+ sections.each do |section|
1456
+ case section[:type]
1457
+ when :header
1458
+ level = section[:level] || 1
1459
+ text = section[:text]
1460
+ if level == 1
1461
+ puts text.upcase
1462
+ puts "=" * text.length
1463
+ else
1464
+ puts text
1465
+ puts "-" * text.length
1466
+ end
1467
+ when :paragraph
1468
+ puts section[:text]
1469
+ when :spacer
1470
+ (section[:rows] || 1).times { puts "" }
1471
+ when :key_value
1472
+ puts "#{section[:label]}: #{section[:value]}"
1473
+ when :bullet_list
1474
+ section[:items].each { |item| puts " * #{item}" }
1475
+ when :numbered_list
1476
+ section[:items].each_with_index { |item, idx| puts " #{idx + 1}. #{item}" }
1477
+ when :table
1478
+ output_custom_sheet_table(section)
1479
+ when :hr
1480
+ puts "-" * 80
1481
+ when :note
1482
+ style_prefix = case section[:style]
1483
+ when :warning then "[!] "
1484
+ when :error then "[X] "
1485
+ when :success then "[+] "
1486
+ else "[i] "
1487
+ end
1488
+ puts "#{style_prefix}#{section[:text]}"
1489
+ end
1490
+ puts ""
1491
+ end
1492
+
1493
+ if data[:generated_at]
1494
+ puts "-" * 80
1495
+ puts "Generated at: #{data[:generated_at]}"
1496
+ end
1497
+ end
1498
+
1499
+ def output_custom_sheet_table(section)
1500
+ headers = section[:headers]
1501
+ rows = section[:rows]
1502
+
1503
+ if section[:title]
1504
+ puts section[:title]
1505
+ puts "-" * 60
1506
+ end
1507
+
1508
+ # Calculate column widths
1509
+ all_rows = [headers] + rows
1510
+ col_widths = headers.map.with_index do |_, idx|
1511
+ all_rows.map { |row| row[idx].to_s.length }.max
1512
+ end
1513
+
1514
+ # Format string
1515
+ format_str = col_widths.map { |w| "%-#{w + 2}s" }.join(" | ")
1516
+
1517
+ # Output header
1518
+ puts format_str % headers
1519
+ puts "-" * (col_widths.sum + (col_widths.length - 1) * 3 + col_widths.length * 2)
1520
+
1521
+ # Output rows
1522
+ rows.each do |row|
1523
+ puts format_str % row
1524
+ end
1525
+ end
1526
+ end
1527
+ end
1528
+ end