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,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ module Reports
5
+ class BaseReport
6
+ attr_reader :model, :start_date, :end_date, :output_currency, :filters
7
+
8
+ def initialize(model, start_date:, end_date:, output_currency: 'USD', filters: {})
9
+ @model = model
10
+ @start_date = start_date
11
+ @end_date = end_date
12
+ @output_currency = output_currency
13
+ @filters = filters
14
+ end
15
+
16
+ # Abstract method - subclasses define which categories to include
17
+ def included_category_types
18
+ raise NotImplementedError, "Subclasses must define included_category_types"
19
+ end
20
+
21
+ # Get categories that should be in this report
22
+ def relevant_categories
23
+ @model.categories.select do |cat|
24
+ included_category_types.include?(cat.type) &&
25
+ matches_filters?(cat) &&
26
+ category_has_relevant_variables?(cat)
27
+ end
28
+ end
29
+
30
+ # Check if category has variables that match project filter
31
+ def category_has_relevant_variables?(category)
32
+ return true unless @filters[:project]
33
+
34
+ # Check if any variable in this category or its descendants matches the project
35
+ all_vars = category.variables + category.descendants.flat_map(&:variables)
36
+ return false if all_vars.empty?
37
+
38
+ all_vars.any? { |var| variable_matches_project?(var, @filters[:project]) }
39
+ end
40
+
41
+ # Check if a variable matches the project filter
42
+ def variable_matches_project?(variable, project_filter)
43
+ return true unless project_filter
44
+
45
+ variable_project = get_variable_project(variable[:name])
46
+ variable_project == project_filter
47
+ end
48
+
49
+ # Get project tag for a variable
50
+ def get_variable_project(variable_name)
51
+ # Check in temporal values metadata
52
+ temporal_value = @model.calculator.instance_variable_get(:@temporal_values)[variable_name]
53
+ if temporal_value
54
+ # Get project from most recent period metadata
55
+ periods = temporal_value.instance_variable_get(:@periods)
56
+ if periods && periods.any?
57
+ latest_period = periods.last
58
+ return latest_period[:metadata][:project] if latest_period[:metadata]
59
+ end
60
+ end
61
+
62
+ # Check in calculated variables metadata
63
+ var_def = @model.calculator.variables[variable_name]
64
+ if var_def.is_a?(Hash) && var_def[:type] == :calculated
65
+ return var_def[:project]
66
+ end
67
+
68
+ # Check in category variables
69
+ @model.categories.each do |cat|
70
+ var = (cat.variables + cat.descendants.flat_map(&:variables)).find { |v| v[:name] == variable_name }
71
+ return var[:project] if var && var[:project]
72
+ end
73
+
74
+ nil
75
+ end
76
+
77
+ # Generate report data structure
78
+ def generate
79
+ {
80
+ metadata: build_metadata,
81
+ sections: build_sections,
82
+ totals: calculate_totals,
83
+ period: { start: @start_date, end: @end_date },
84
+ currency: @output_currency
85
+ }
86
+ end
87
+
88
+ # Output to a specific format
89
+ def output(format_class, options = {})
90
+ output_handler = format_class.new(self, options)
91
+ output_handler.generate
92
+ end
93
+
94
+ protected
95
+
96
+ def build_metadata
97
+ {
98
+ report_type: self.class.name.split('::').last,
99
+ generated_at: Time.now,
100
+ currency: @output_currency,
101
+ filters: @filters
102
+ }
103
+ end
104
+
105
+ def build_sections
106
+ raise NotImplementedError, "Subclasses must implement build_sections"
107
+ end
108
+
109
+ def calculate_totals
110
+ raise NotImplementedError, "Subclasses must implement calculate_totals"
111
+ end
112
+
113
+ def calculate_value(variable_name, date = @end_date, period_type: :annual)
114
+ @model.variable_value(variable_name,
115
+ date: date,
116
+ period_type: period_type
117
+ )
118
+ end
119
+
120
+ # Get period type for this report
121
+ def period_type
122
+ @model.determine_period_type(@start_date, @end_date)
123
+ end
124
+
125
+ def format_money(value)
126
+ return 0 unless value
127
+
128
+ if value.is_a?(Money)
129
+ value
130
+ else
131
+ Money.new((value * 100).to_i, @output_currency)
132
+ end
133
+ end
134
+
135
+ def humanize_name(name)
136
+ name.to_s.split('_').map(&:capitalize).join(' ')
137
+ end
138
+
139
+ # Generate dates for a period range
140
+ def generate_period_dates(start_date, end_date, frequency)
141
+ dates = []
142
+ current = parse_date(start_date)
143
+ end_dt = parse_date(end_date)
144
+
145
+ while current <= end_dt
146
+ dates << current
147
+ current = case frequency
148
+ when :daily
149
+ current + 1
150
+ when :weekly
151
+ current + 7
152
+ when :monthly
153
+ current >> 1
154
+ when :quarterly
155
+ current >> 3
156
+ when :annual
157
+ current >> 12
158
+ else
159
+ current >> 1 # Default monthly
160
+ end
161
+ end
162
+
163
+ dates
164
+ end
165
+
166
+ def parse_date(date)
167
+ return date if date.is_a?(Date)
168
+ return nil if date.nil?
169
+
170
+ case date
171
+ when String
172
+ if date =~ /^\d{4}-\d{2}$/ # YYYY-MM format
173
+ Date.parse("#{date}-01")
174
+ else
175
+ Date.parse(date)
176
+ end
177
+ when Time
178
+ date.to_date
179
+ when Integer
180
+ # Assume year
181
+ Date.new(date, 1, 1)
182
+ else
183
+ date
184
+ end
185
+ end
186
+
187
+ # Get section value from report data
188
+ def extract_section_value(report_data, section_name)
189
+ sections = report_data[:sections] || {}
190
+ section = sections[section_name.to_sym] || sections[section_name.to_s]
191
+ section ? (section[:total] || section[:value] || 0) : nil
192
+ end
193
+
194
+ # Count items in a section
195
+ def count_section_items(report_data, section_name)
196
+ sections = report_data[:sections] || {}
197
+ section = sections[section_name.to_sym] || sections[section_name.to_s]
198
+ return 0 unless section
199
+
200
+ items = section[:items] || []
201
+ count = items.length
202
+
203
+ # Count subcategories recursively
204
+ items.each do |item|
205
+ if item[:subcategories]
206
+ count += item[:subcategories].length
207
+ end
208
+ end
209
+
210
+ count
211
+ end
212
+
213
+ private
214
+
215
+ def matches_filters?(category)
216
+ return true if @filters.empty?
217
+
218
+ # Support filtering by name pattern, parent, project, etc.
219
+ @filters.all? do |key, value|
220
+ case key
221
+ when :name_includes
222
+ category.name.to_s.include?(value.to_s)
223
+ when :parent
224
+ category.parent&.name == value
225
+ when :exclude
226
+ !Array(value).include?(category.name)
227
+ when :only
228
+ Array(value).include?(category.name)
229
+ when :project
230
+ # Project filtering is handled by category_has_relevant_variables?
231
+ true
232
+ else
233
+ true
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,480 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ module Reports
5
+ class CashFlowStatement < BaseReport
6
+ attr_accessor :method, :display_mode
7
+
8
+ # Include categories that affect cash flow
9
+ def included_category_types
10
+ [:cash_inflow, :cash_outflow, :income, :expense, :asset, :liability]
11
+ end
12
+
13
+ def initialize(model, start_date:, end_date:, output_currency: 'USD', filters: {}, method: :indirect, display_mode: :standard)
14
+ super(model, start_date: start_date, end_date: end_date, output_currency: output_currency, filters: filters)
15
+ @method = method # :direct or :indirect
16
+ @display_mode = display_mode # :standard (3 sections) or :simple (cash in/out)
17
+ @period_type = model.determine_period_type(start_date, end_date)
18
+ end
19
+
20
+ def generate
21
+ data = super
22
+ data[:method] = @method
23
+ data[:display_mode] = @display_mode
24
+ data
25
+ end
26
+
27
+ def build_sections
28
+ sections = {}
29
+
30
+ case @display_mode
31
+ when :simple
32
+ sections.merge!(build_simple_sections)
33
+ else # :standard
34
+ sections.merge!(build_standard_sections)
35
+ end
36
+
37
+ sections
38
+ end
39
+
40
+ def calculate_totals
41
+ sections = build_sections
42
+
43
+ if @display_mode == :simple
44
+ {
45
+ cash_in: sections[:cash_in][:total] || 0,
46
+ cash_out: sections[:cash_out][:total] || 0,
47
+ operating: 0,
48
+ investing: 0,
49
+ financing: 0,
50
+ net_change_in_cash: sections[:net_change_in_cash][:total] || 0,
51
+ beginning_cash: sections[:beginning_cash][:total] || 0,
52
+ ending_cash: sections[:ending_cash][:total] || 0
53
+ }
54
+ else
55
+ {
56
+ operating: sections[:operating][:total] || 0,
57
+ investing: sections[:investing][:total] || 0,
58
+ financing: sections[:financing][:total] || 0,
59
+ net_change_in_cash: sections[:net_change_in_cash][:total] || 0,
60
+ beginning_cash: sections[:beginning_cash][:total] || 0,
61
+ ending_cash: sections[:ending_cash][:total] || 0
62
+ }
63
+ end
64
+ end
65
+
66
+ # Generate monthly reports for a date range
67
+ def generate_monthly(start_date, end_date)
68
+ dates = generate_period_dates(start_date, end_date, :monthly)
69
+ dates.map do |date|
70
+ month_start = Date.new(date.year, date.month, 1)
71
+ month_end = Date.new(date.year, date.month, -1)
72
+ report = self.class.new(
73
+ @model,
74
+ start_date: month_start,
75
+ end_date: month_end,
76
+ output_currency: @output_currency,
77
+ filters: @filters,
78
+ method: @method,
79
+ display_mode: @display_mode
80
+ )
81
+ {
82
+ period: { start: month_start, end: month_end },
83
+ report: report.generate
84
+ }
85
+ end
86
+ end
87
+
88
+ # Get summary statistics for periods
89
+ def period_summary(period_type: :monthly)
90
+ periods = get_periods(frequency: period_type)
91
+ summaries = periods.map do |date|
92
+ period_start = case period_type
93
+ when :monthly
94
+ Date.new(date.year, date.month, 1)
95
+ when :quarterly
96
+ quarter_month = ((date.month - 1) / 3) * 3 + 1
97
+ Date.new(date.year, quarter_month, 1)
98
+ when :annual
99
+ Date.new(date.year, 1, 1)
100
+ else
101
+ date
102
+ end
103
+
104
+ period_end = case period_type
105
+ when :monthly
106
+ Date.new(date.year, date.month, -1)
107
+ when :quarterly
108
+ quarter_month = ((date.month - 1) / 3) * 3 + 3
109
+ Date.new(date.year, quarter_month, -1)
110
+ when :annual
111
+ Date.new(date.year, 12, 31)
112
+ else
113
+ date
114
+ end
115
+
116
+ report = self.class.new(
117
+ @model,
118
+ start_date: period_start,
119
+ end_date: period_end,
120
+ output_currency: @output_currency,
121
+ filters: @filters,
122
+ method: @method,
123
+ display_mode: @display_mode
124
+ )
125
+
126
+ report_data = report.generate
127
+ totals = report_data[:totals] || {}
128
+
129
+ {
130
+ period: { start: period_start, end: period_end },
131
+ report: report_data,
132
+ operating: totals[:operating] || 0,
133
+ investing: totals[:investing] || 0,
134
+ financing: totals[:financing] || 0,
135
+ net_change_in_cash: totals[:net_change_in_cash] || 0
136
+ }
137
+ end
138
+
139
+ {
140
+ frequency: period_type,
141
+ periods: summaries,
142
+ summary: {
143
+ total_operating: summaries.sum { |s| s[:operating] || 0 },
144
+ total_investing: summaries.sum { |s| s[:investing] || 0 },
145
+ total_financing: summaries.sum { |s| s[:financing] || 0 },
146
+ total_net_change: summaries.sum { |s| s[:net_change_in_cash] || 0 },
147
+ period_count: summaries.length
148
+ }
149
+ }
150
+ end
151
+
152
+ def get_periods(frequency: :monthly)
153
+ generate_period_dates(@start_date, @end_date, frequency)
154
+ end
155
+
156
+ private
157
+
158
+ def build_standard_sections
159
+ sections = {}
160
+
161
+ # OPERATING ACTIVITIES
162
+ sections[:operating] = build_operating_section
163
+
164
+ # INVESTING ACTIVITIES
165
+ sections[:investing] = build_investing_section
166
+
167
+ # FINANCING ACTIVITIES
168
+ sections[:financing] = build_financing_section
169
+
170
+ # Calculate totals
171
+ operating_total = sections[:operating][:total] || 0
172
+ investing_total = sections[:investing][:total] || 0
173
+ financing_total = sections[:financing][:total] || 0
174
+
175
+ net_change = operating_total + investing_total + financing_total
176
+
177
+ # NET CHANGE IN CASH
178
+ sections[:net_change_in_cash] = {
179
+ label: "Net Change in Cash",
180
+ formula: "operating + investing + financing",
181
+ value: net_change,
182
+ total: net_change
183
+ }
184
+
185
+ # BEGINNING CASH BALANCE
186
+ beginning_cash = calculate_beginning_cash
187
+ sections[:beginning_cash] = {
188
+ label: "Beginning Cash Balance",
189
+ value: beginning_cash,
190
+ total: beginning_cash
191
+ }
192
+
193
+ # ENDING CASH BALANCE
194
+ ending_cash = beginning_cash + net_change
195
+ sections[:ending_cash] = {
196
+ label: "Ending Cash Balance",
197
+ formula: "beginning_cash + net_change_in_cash",
198
+ value: ending_cash,
199
+ total: ending_cash
200
+ }
201
+
202
+ sections
203
+ end
204
+
205
+ def build_simple_sections
206
+ sections = {}
207
+
208
+ # Simple mode: just cash in and cash out
209
+ cash_in = calculate_total_cash_in
210
+ cash_out = calculate_total_cash_out
211
+
212
+ sections[:cash_in] = {
213
+ name: "Cash Inflows",
214
+ items: build_cash_in_items,
215
+ total: cash_in,
216
+ formula: "total of all cash inflows"
217
+ }
218
+
219
+ sections[:cash_out] = {
220
+ name: "Cash Outflows",
221
+ items: build_cash_out_items,
222
+ total: cash_out,
223
+ formula: "total of all cash outflows"
224
+ }
225
+
226
+ net_change = cash_in - cash_out
227
+
228
+ sections[:net_change_in_cash] = {
229
+ label: "Net Change in Cash",
230
+ formula: "cash_in - cash_out",
231
+ value: net_change,
232
+ total: net_change
233
+ }
234
+
235
+ beginning_cash = calculate_beginning_cash
236
+ sections[:beginning_cash] = {
237
+ label: "Beginning Cash Balance",
238
+ value: beginning_cash,
239
+ total: beginning_cash
240
+ }
241
+
242
+ ending_cash = beginning_cash + net_change
243
+ sections[:ending_cash] = {
244
+ label: "Ending Cash Balance",
245
+ formula: "beginning_cash + net_change_in_cash",
246
+ value: ending_cash,
247
+ total: ending_cash
248
+ }
249
+
250
+ sections
251
+ end
252
+
253
+ def build_operating_section
254
+ items = []
255
+ total = 0
256
+
257
+ case @method
258
+ when :indirect
259
+ # Start with net income
260
+ net_income = @model.period_net_income(@start_date, @end_date,
261
+ output_currency: @output_currency, filters: @filters)
262
+
263
+ items << {
264
+ name: :net_income,
265
+ display_name: "Net Income",
266
+ value: net_income,
267
+ indent: 0
268
+ }
269
+ total += net_income
270
+
271
+ # Add back non-cash expenses (depreciation, amortization)
272
+ # For now, we assume all income/expenses are cash-based
273
+ # Future: detect and add back depreciation accounts
274
+
275
+ # Working capital changes would go here
276
+ # For the concert model, all transactions flow through checking
277
+ # so net income = operating cash flow
278
+
279
+ when :direct
280
+ # Direct method: list actual cash receipts and payments
281
+ # Use cash_inflow and cash_outflow categories
282
+ cash_inflow_categories = @model.categories.select { |c| c.type == :cash_inflow }
283
+ cash_outflow_categories = @model.categories.select { |c| c.type == :cash_outflow }
284
+
285
+ # Add cash inflows
286
+ cash_inflow_categories.each do |category|
287
+ item = build_category_item(category)
288
+ next unless item
289
+ items << item
290
+ total += item[:value] || 0
291
+ end
292
+
293
+ # Add cash outflows (as negative)
294
+ cash_outflow_categories.each do |category|
295
+ item = build_category_item(category)
296
+ next unless item
297
+ item[:value] = -(item[:value] || 0).abs
298
+ items << item
299
+ total += item[:value]
300
+ end
301
+ end
302
+
303
+ {
304
+ name: "Operating Activities",
305
+ items: items,
306
+ total: total,
307
+ formula: @method == :indirect ? "net_income + adjustments" : "cash_receipts - cash_payments"
308
+ }
309
+ end
310
+
311
+ def build_investing_section
312
+ items = []
313
+ total = 0
314
+
315
+ # Look for fixed asset accounts and calculate changes
316
+ # For the concert model, there are no investing activities
317
+ # Future: detect asset purchases/sales
318
+
319
+ {
320
+ name: "Investing Activities",
321
+ items: items,
322
+ total: total,
323
+ formula: "asset_sales - asset_purchases"
324
+ }
325
+ end
326
+
327
+ def build_financing_section
328
+ items = []
329
+ total = 0
330
+
331
+ # Look for liability/equity changes
332
+ # For the concert model, there are no financing activities
333
+ # Future: detect debt/equity transactions
334
+
335
+ {
336
+ name: "Financing Activities",
337
+ items: items,
338
+ total: total,
339
+ formula: "debt_proceeds + equity_contributions - debt_payments - dividends"
340
+ }
341
+ end
342
+
343
+ def calculate_beginning_cash
344
+ # Get cash balance at start of period
345
+ cash_accounts = @model.accounts.values.select { |acc|
346
+ acc.type == :asset && is_cash_account?(acc)
347
+ }
348
+
349
+ # Calculate balance at start_date - 1 day (end of previous period)
350
+ previous_date = @start_date - 1
351
+
352
+ # If previous date is before model start, use opening balance
353
+ if previous_date < @model.start_date
354
+ cash_accounts.sum { |account| account.opening_balance.to_f }
355
+ else
356
+ cash_accounts.sum do |account|
357
+ @model.account_balance(account.name, as_of_date: previous_date)
358
+ end
359
+ end
360
+ end
361
+
362
+ def calculate_total_cash_in
363
+ # Sum all positive flows to cash accounts
364
+ cash_accounts = @model.accounts.values.select { |acc|
365
+ acc.type == :asset && is_cash_account?(acc)
366
+ }
367
+
368
+ total = 0
369
+ cash_accounts.each do |account|
370
+ flow = @model.account_period_flow(account.name, @start_date, @end_date)
371
+ total += flow if flow > 0
372
+ end
373
+ total
374
+ end
375
+
376
+ def calculate_total_cash_out
377
+ # Sum all negative flows from cash accounts (as positive number)
378
+ cash_accounts = @model.accounts.values.select { |acc|
379
+ acc.type == :asset && is_cash_account?(acc)
380
+ }
381
+
382
+ total = 0
383
+ cash_accounts.each do |account|
384
+ flow = @model.account_period_flow(account.name, @start_date, @end_date)
385
+ total += flow.abs if flow < 0
386
+ end
387
+ total
388
+ end
389
+
390
+ def build_cash_in_items
391
+ items = []
392
+
393
+ # Group cash inflows by source (income categories)
394
+ income_categories = @model.categories.select { |c| c.type == :income }
395
+
396
+ income_categories.each do |category|
397
+ value = @model.category_total_for_period(
398
+ category,
399
+ @start_date,
400
+ @end_date,
401
+ period_type: @period_type,
402
+ output_currency: @output_currency,
403
+ filters: @filters
404
+ )
405
+
406
+ next if value == 0
407
+
408
+ items << {
409
+ name: category.name,
410
+ display_name: category.description || humanize_name(category.name),
411
+ value: value,
412
+ indent: 0
413
+ }
414
+ end
415
+
416
+ items
417
+ end
418
+
419
+ def build_cash_out_items
420
+ items = []
421
+
422
+ # Group cash outflows by category (expense categories)
423
+ expense_categories = @model.categories.select { |c| c.type == :expense }
424
+
425
+ expense_categories.each do |category|
426
+ value = @model.category_total_for_period(
427
+ category,
428
+ @start_date,
429
+ @end_date,
430
+ period_type: @period_type,
431
+ output_currency: @output_currency,
432
+ filters: @filters
433
+ )
434
+
435
+ # Expense values are typically negative in account flows
436
+ value = value.abs if value.is_a?(Numeric)
437
+
438
+ next if value == 0
439
+
440
+ items << {
441
+ name: category.name,
442
+ display_name: category.description || humanize_name(category.name),
443
+ value: value,
444
+ indent: 0
445
+ }
446
+ end
447
+
448
+ items
449
+ end
450
+
451
+ def build_category_item(category)
452
+ value = @model.category_total_for_period(
453
+ category,
454
+ @start_date,
455
+ @end_date,
456
+ period_type: @period_type,
457
+ output_currency: @output_currency,
458
+ filters: @filters
459
+ )
460
+
461
+ return nil if value == 0
462
+
463
+ {
464
+ name: category.name,
465
+ display_name: category.description || humanize_name(category.name),
466
+ value: value,
467
+ indent: 0
468
+ }
469
+ end
470
+
471
+ def is_cash_account?(account)
472
+ # Determine if account is a cash account
473
+ # Check by name patterns
474
+ name = account.name.to_s.downcase
475
+ cash_patterns = ['cash', 'checking', 'savings', 'bank', 'money_market']
476
+ cash_patterns.any? { |pattern| name.include?(pattern) }
477
+ end
478
+ end
479
+ end
480
+ end