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,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ module Reports
5
+ # Report for comparing metrics across multiple time periods within the same model
6
+ # Useful for Q1 vs Q2 vs Q3 vs Q4 comparisons, year-over-year, etc.
7
+ #
8
+ # Usage:
9
+ # comparison = FinIt::Reports::PeriodComparison.new(
10
+ # model,
11
+ # periods: [
12
+ # { name: "Q1 2025", start_date: "2025-01-01", end_date: "2025-03-31" },
13
+ # { name: "Q2 2025", start_date: "2025-04-01", end_date: "2025-06-30" },
14
+ # { name: "Q3 2025", start_date: "2025-07-01", end_date: "2025-09-30" },
15
+ # { name: "Q4 2025", start_date: "2025-10-01", end_date: "2025-12-31" }
16
+ # ],
17
+ # metrics: [:net_income, :total_revenue, :total_expenses],
18
+ # output_currency: 'USD'
19
+ # )
20
+ #
21
+ class PeriodComparison
22
+ attr_reader :model, :periods, :metrics, :output_currency
23
+
24
+ # Alias for compatibility
25
+ alias_method :currency, :output_currency
26
+
27
+ BUILT_IN_METRICS = [:net_income, :total_revenue, :total_income, :total_expenses,
28
+ :ending_cash, :beginning_cash, :gross_profit, :operating_income].freeze
29
+
30
+ def initialize(model, periods:, metrics:, output_currency: 'USD')
31
+ @model = model
32
+ @periods = normalize_periods(periods)
33
+ @metrics = metrics
34
+ @output_currency = output_currency
35
+ @normalized_metrics = normalize_metrics
36
+ end
37
+
38
+ def generate
39
+ # Generate transactions up to the last period end date
40
+ last_end_date = @periods.map { |p| p[:end_date] }.max
41
+ @model.generate_transactions(last_end_date)
42
+
43
+ {
44
+ report_type: "PeriodComparison",
45
+ metadata: build_metadata,
46
+ periods: calculate_all_periods,
47
+ variances: calculate_period_variances
48
+ }
49
+ end
50
+
51
+ def output(output_class, **options)
52
+ output_class.new(self, options).generate
53
+ end
54
+
55
+ # Get normalized metric configs for external use
56
+ def normalized_metrics
57
+ @normalized_metrics
58
+ end
59
+
60
+ private
61
+
62
+ def normalize_periods(periods)
63
+ periods.map do |p|
64
+ {
65
+ name: p[:name],
66
+ start_date: parse_date(p[:start_date]),
67
+ end_date: parse_date(p[:end_date])
68
+ }
69
+ end
70
+ end
71
+
72
+ def normalize_metrics
73
+ @metrics.map do |metric|
74
+ case metric
75
+ when Symbol
76
+ {
77
+ variable: metric,
78
+ label: humanize_name(metric),
79
+ format: :currency
80
+ }
81
+ when Hash
82
+ var = metric[:variable]
83
+ raise ArgumentError, "Metric hash requires :variable key" unless var
84
+
85
+ {
86
+ variable: var,
87
+ label: metric[:label] || humanize_name(var),
88
+ format: metric[:format] || :currency
89
+ }
90
+ else
91
+ raise ArgumentError, "Invalid metric: #{metric.inspect}. Must be Symbol or Hash."
92
+ end
93
+ end
94
+ end
95
+
96
+ def humanize_name(name)
97
+ name.to_s.split('_').map(&:capitalize).join(' ')
98
+ end
99
+
100
+ def build_metadata
101
+ {
102
+ report_type: "PeriodComparison",
103
+ periods: @periods,
104
+ metrics: @metrics,
105
+ normalized_metrics: @normalized_metrics,
106
+ currency: @output_currency,
107
+ generated_at: Time.now
108
+ }
109
+ end
110
+
111
+ def calculate_all_periods
112
+ @periods.map do |period|
113
+ {
114
+ name: period[:name],
115
+ start_date: period[:start_date],
116
+ end_date: period[:end_date],
117
+ metrics: calculate_metrics_for_period(period)
118
+ }
119
+ end
120
+ end
121
+
122
+ def calculate_metrics_for_period(period)
123
+ result = {}
124
+ @normalized_metrics.each do |metric_config|
125
+ key = metric_config[:variable]
126
+ result[key] = calculate_single_metric(metric_config, period)
127
+ end
128
+ result
129
+ end
130
+
131
+ def calculate_single_metric(metric_config, period)
132
+ var_name = metric_config[:variable]
133
+ start_date = period[:start_date]
134
+ end_date = period[:end_date]
135
+
136
+ case var_name
137
+ when :net_income
138
+ value = @model.period_net_income(start_date, end_date, output_currency: @output_currency)
139
+ value.is_a?(Money) ? value.to_f : value.to_f
140
+ when :total_revenue, :total_income
141
+ calculate_total_income(start_date, end_date)
142
+ when :total_expenses
143
+ calculate_total_expenses(start_date, end_date)
144
+ when :ending_cash
145
+ calculate_ending_cash(end_date)
146
+ when :beginning_cash
147
+ calculate_beginning_cash(start_date)
148
+ when :gross_profit
149
+ calculate_gross_profit(start_date, end_date)
150
+ when :operating_income
151
+ calculate_operating_income(start_date, end_date)
152
+ else
153
+ # Custom variable
154
+ value = @model.calculator.calculate(
155
+ var_name,
156
+ date: end_date,
157
+ output_currency: @output_currency,
158
+ period_type: :annual
159
+ )
160
+ value.is_a?(Money) ? value.to_f : value.to_f
161
+ end
162
+ end
163
+
164
+ def calculate_total_income(start_date, end_date)
165
+ income_categories = @model.categories.select { |c| c.type == :income }
166
+ total = income_categories.sum do |category|
167
+ @model.category_total_for_period(
168
+ category,
169
+ start_date,
170
+ end_date,
171
+ period_type: :annual,
172
+ output_currency: @output_currency
173
+ ).abs
174
+ end
175
+ total.is_a?(Money) ? total.to_f : total.to_f
176
+ end
177
+
178
+ def calculate_total_expenses(start_date, end_date)
179
+ expense_categories = @model.categories.select { |c| c.type == :expense }
180
+ total = expense_categories.sum do |category|
181
+ @model.category_total_for_period(
182
+ category,
183
+ start_date,
184
+ end_date,
185
+ period_type: :annual,
186
+ output_currency: @output_currency
187
+ ).abs
188
+ end
189
+ total.is_a?(Money) ? total.to_f : total.to_f
190
+ end
191
+
192
+ def calculate_ending_cash(end_date)
193
+ cash_accounts = @model.accounts.values.select do |acc|
194
+ acc.type == :asset && is_cash_account?(acc)
195
+ end
196
+
197
+ total = cash_accounts.sum do |account|
198
+ @model.account_balance(account.name, as_of_date: end_date)
199
+ end
200
+
201
+ total.is_a?(Money) ? total.to_f : total.to_f
202
+ end
203
+
204
+ def calculate_beginning_cash(start_date)
205
+ cash_accounts = @model.accounts.values.select do |acc|
206
+ acc.type == :asset && is_cash_account?(acc)
207
+ end
208
+
209
+ previous_date = start_date - 1
210
+
211
+ if previous_date < @model.start_date
212
+ cash_accounts.sum { |acc| acc.opening_balance.to_f }
213
+ else
214
+ cash_accounts.sum do |account|
215
+ @model.account_balance(account.name, as_of_date: previous_date)
216
+ end
217
+ end
218
+ end
219
+
220
+ def calculate_gross_profit(start_date, end_date)
221
+ total_income = calculate_total_income(start_date, end_date)
222
+
223
+ cogs_categories = @model.categories.select do |c|
224
+ c.type == :expense &&
225
+ (c.name.to_s.include?('cogs') || c.name.to_s.include?('cost_of_goods'))
226
+ end
227
+
228
+ total_cogs = cogs_categories.sum do |category|
229
+ @model.category_total_for_period(
230
+ category, start_date, end_date,
231
+ period_type: :annual, output_currency: @output_currency
232
+ ).abs
233
+ end
234
+
235
+ total_income - (total_cogs.is_a?(Money) ? total_cogs.to_f : total_cogs.to_f)
236
+ end
237
+
238
+ def calculate_operating_income(start_date, end_date)
239
+ calculate_gross_profit(start_date, end_date) - calculate_total_expenses(start_date, end_date)
240
+ end
241
+
242
+ def is_cash_account?(account)
243
+ name = account.name.to_s.downcase
244
+ cash_patterns = ['cash', 'checking', 'savings', 'bank', 'money_market']
245
+ cash_patterns.any? { |pattern| name.include?(pattern) }
246
+ end
247
+
248
+ def calculate_period_variances
249
+ # Compare each period to the previous period
250
+ results = []
251
+
252
+ @periods.each_with_index do |period, idx|
253
+ if idx == 0
254
+ # First period has no comparison
255
+ results << {
256
+ name: period[:name],
257
+ variances: nil
258
+ }
259
+ else
260
+ prev_period = @periods[idx - 1]
261
+ prev_metrics = calculate_metrics_for_period(prev_period)
262
+ curr_metrics = calculate_metrics_for_period(period)
263
+
264
+ variances = {}
265
+ @normalized_metrics.each do |metric_config|
266
+ key = metric_config[:variable]
267
+ prev_val = prev_metrics[key] || 0
268
+ curr_val = curr_metrics[key] || 0
269
+
270
+ variances[key] = {
271
+ absolute: curr_val - prev_val,
272
+ percentage: prev_val != 0 ?
273
+ ((curr_val - prev_val) / prev_val.to_f * 100).round(2) : 0.0
274
+ }
275
+ end
276
+
277
+ results << {
278
+ name: period[:name],
279
+ variances: variances
280
+ }
281
+ end
282
+ end
283
+
284
+ results
285
+ end
286
+
287
+ def parse_date(date)
288
+ return date if date.is_a?(Date)
289
+
290
+ case date
291
+ when String
292
+ if date =~ /^\d{4}$/
293
+ Date.new(date.to_i, 1, 1)
294
+ elsif date =~ /^\d{4}-\d{2}$/
295
+ Date.parse("#{date}-01")
296
+ else
297
+ Date.parse(date)
298
+ end
299
+ when Integer
300
+ Date.new(date, 1, 1)
301
+ when Time
302
+ date.to_date
303
+ else
304
+ raise ArgumentError, "Cannot parse date: #{date}"
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ module Reports
5
+ # Report for comparing base model vs one or more scenario models
6
+ # Supports both simple symbol metrics and hash-based metric configurations:
7
+ #
8
+ # Simple: :net_income, :total_expenses
9
+ # Hash: { variable: :revenue, period_type: :monthly, label: "Monthly Revenue" }
10
+ #
11
+ class ScenarioComparison
12
+ attr_reader :base_model, :scenarios, :start_date, :end_date, :output_currency, :metrics
13
+
14
+ # Alias for compatibility with BaseOutput
15
+ alias_method :currency, :output_currency
16
+
17
+ DEFAULT_METRICS = [:net_income, :total_revenue, :total_expenses, :ending_cash].freeze
18
+ BUILT_IN_METRICS = [:net_income, :total_revenue, :total_income, :total_expenses,
19
+ :ending_cash, :beginning_cash, :gross_profit, :operating_income].freeze
20
+
21
+ def initialize(base_model, scenarios:, start_date:, end_date:,
22
+ output_currency: 'USD', metrics: DEFAULT_METRICS)
23
+ @base_model = base_model
24
+ @scenarios = scenarios # Hash of { name: model }
25
+ @start_date = parse_date(start_date)
26
+ @end_date = parse_date(end_date)
27
+ @output_currency = output_currency
28
+ @metrics = metrics
29
+ @normalized_metrics = normalize_metrics
30
+ end
31
+
32
+ def generate
33
+ # Ensure transactions are generated for all models
34
+ @base_model.generate_transactions(@end_date)
35
+ @scenarios.each_value { |model| model.generate_transactions(@end_date) }
36
+
37
+ base_metrics = calculate_metrics(@base_model)
38
+
39
+ {
40
+ report_type: "ScenarioComparison",
41
+ metadata: build_metadata,
42
+ base: base_metrics,
43
+ scenarios: @scenarios.transform_values { |model| calculate_metrics(model) },
44
+ variances: calculate_variances(base_metrics)
45
+ }
46
+ end
47
+
48
+ # Get normalized metric configs for external use (e.g., outputs)
49
+ def normalized_metrics
50
+ @normalized_metrics
51
+ end
52
+
53
+ # Output to a specific output class
54
+ def output(output_class, **options)
55
+ output_class.new(self, options).generate
56
+ end
57
+
58
+ private
59
+
60
+ # Normalize metrics to a consistent hash format
61
+ # Supports both simple symbols and hash configurations
62
+ def normalize_metrics
63
+ @metrics.map do |metric|
64
+ case metric
65
+ when Symbol
66
+ {
67
+ variable: metric,
68
+ period_type: :annual,
69
+ start_date: @start_date,
70
+ end_date: @end_date,
71
+ label: humanize_name(metric),
72
+ format: :currency,
73
+ key: metric # Key used in result hash
74
+ }
75
+ when Hash
76
+ var = metric[:variable]
77
+ raise ArgumentError, "Metric hash requires :variable key" unless var
78
+
79
+ # Generate key: use explicit :key, or build from variable + period_name, or just variable
80
+ key = if metric[:key]
81
+ metric[:key]
82
+ elsif metric[:period_name]
83
+ :"#{var}_#{metric[:period_name].downcase.gsub(' ', '_')}"
84
+ else
85
+ var
86
+ end
87
+
88
+ {
89
+ variable: var,
90
+ period_type: metric[:period_type] || :annual,
91
+ start_date: metric[:start_date] ? parse_date(metric[:start_date]) : @start_date,
92
+ end_date: metric[:end_date] ? parse_date(metric[:end_date]) : @end_date,
93
+ label: metric[:label] || (metric[:period_name] ? "#{humanize_name(var)} (#{metric[:period_name]})" : humanize_name(var)),
94
+ format: metric[:format] || :currency,
95
+ key: key
96
+ }
97
+ else
98
+ raise ArgumentError, "Invalid metric: #{metric.inspect}. Must be Symbol or Hash."
99
+ end
100
+ end
101
+ end
102
+
103
+ def humanize_name(name)
104
+ name.to_s.split('_').map(&:capitalize).join(' ')
105
+ end
106
+
107
+ def build_metadata
108
+ {
109
+ report_type: "ScenarioComparison",
110
+ start_date: @start_date,
111
+ end_date: @end_date,
112
+ currency: @output_currency,
113
+ base_model_name: "Base",
114
+ scenario_names: @scenarios.keys,
115
+ metrics: @metrics,
116
+ normalized_metrics: @normalized_metrics,
117
+ generated_at: Time.now
118
+ }
119
+ end
120
+
121
+ def calculate_metrics(model)
122
+ result = {}
123
+
124
+ @normalized_metrics.each do |metric_config|
125
+ key = metric_config[:key]
126
+ result[key] = calculate_single_metric(model, metric_config)
127
+ end
128
+
129
+ result
130
+ end
131
+
132
+ def calculate_single_metric(model, metric_config)
133
+ var_name = metric_config[:variable]
134
+ start_date = metric_config[:start_date]
135
+ end_date = metric_config[:end_date]
136
+ period_type = metric_config[:period_type]
137
+
138
+ case var_name
139
+ when :net_income
140
+ value = model.period_net_income(start_date, end_date, output_currency: @output_currency)
141
+ value.is_a?(Money) ? value.to_f : value.to_f
142
+ when :total_revenue, :total_income
143
+ calculate_total_income(model, start_date, end_date)
144
+ when :total_expenses
145
+ calculate_total_expenses(model, start_date, end_date)
146
+ when :ending_cash
147
+ calculate_ending_cash(model, end_date)
148
+ when :beginning_cash
149
+ calculate_beginning_cash(model, start_date)
150
+ when :gross_profit
151
+ calculate_gross_profit(model, start_date, end_date)
152
+ when :operating_income
153
+ calculate_operating_income(model, start_date, end_date)
154
+ else
155
+ # Custom variable - use period_type
156
+ value = model.calculator.calculate(
157
+ var_name,
158
+ date: end_date,
159
+ output_currency: @output_currency,
160
+ period_type: period_type
161
+ )
162
+ value.is_a?(Money) ? value.to_f : value.to_f
163
+ end
164
+ end
165
+
166
+ def calculate_total_income(model, start_date, end_date)
167
+ income_categories = model.categories.select { |c| c.type == :income }
168
+ total = income_categories.sum do |category|
169
+ model.category_total_for_period(
170
+ category,
171
+ start_date,
172
+ end_date,
173
+ period_type: :annual,
174
+ output_currency: @output_currency
175
+ ).abs
176
+ end
177
+ total.is_a?(Money) ? total.to_f : total.to_f
178
+ end
179
+
180
+ def calculate_total_expenses(model, start_date, end_date)
181
+ expense_categories = model.categories.select { |c| c.type == :expense }
182
+ total = expense_categories.sum do |category|
183
+ model.category_total_for_period(
184
+ category,
185
+ start_date,
186
+ end_date,
187
+ period_type: :annual,
188
+ output_currency: @output_currency
189
+ ).abs
190
+ end
191
+ total.is_a?(Money) ? total.to_f : total.to_f
192
+ end
193
+
194
+ def calculate_ending_cash(model, end_date)
195
+ # Find cash accounts (checking, savings, etc.)
196
+ cash_accounts = model.accounts.values.select do |acc|
197
+ acc.type == :asset && is_cash_account?(acc)
198
+ end
199
+
200
+ total = cash_accounts.sum do |account|
201
+ model.account_balance(account.name, as_of_date: end_date)
202
+ end
203
+
204
+ total.is_a?(Money) ? total.to_f : total.to_f
205
+ end
206
+
207
+ def calculate_beginning_cash(model, start_date)
208
+ cash_accounts = model.accounts.values.select do |acc|
209
+ acc.type == :asset && is_cash_account?(acc)
210
+ end
211
+
212
+ previous_date = start_date - 1
213
+
214
+ if previous_date < model.start_date
215
+ cash_accounts.sum { |acc| acc.opening_balance.to_f }
216
+ else
217
+ cash_accounts.sum do |account|
218
+ model.account_balance(account.name, as_of_date: previous_date)
219
+ end
220
+ end
221
+ end
222
+
223
+ def calculate_gross_profit(model, start_date, end_date)
224
+ # Gross profit = Revenue - COGS
225
+ # Look for expense categories with 'cogs' or 'cost_of_goods' in name
226
+ total_income = calculate_total_income(model, start_date, end_date)
227
+
228
+ cogs_categories = model.categories.select do |c|
229
+ c.type == :expense &&
230
+ (c.name.to_s.include?('cogs') || c.name.to_s.include?('cost_of_goods'))
231
+ end
232
+
233
+ total_cogs = cogs_categories.sum do |category|
234
+ model.category_total_for_period(
235
+ category, start_date, end_date,
236
+ period_type: :annual, output_currency: @output_currency
237
+ ).abs
238
+ end
239
+
240
+ total_income - (total_cogs.is_a?(Money) ? total_cogs.to_f : total_cogs.to_f)
241
+ end
242
+
243
+ def calculate_operating_income(model, start_date, end_date)
244
+ # Operating income = Gross Profit - Operating Expenses
245
+ calculate_gross_profit(model, start_date, end_date) - calculate_total_expenses(model, start_date, end_date)
246
+ end
247
+
248
+ def is_cash_account?(account)
249
+ name = account.name.to_s.downcase
250
+ cash_patterns = ['cash', 'checking', 'savings', 'bank', 'money_market']
251
+ cash_patterns.any? { |pattern| name.include?(pattern) }
252
+ end
253
+
254
+ def calculate_variances(base_metrics)
255
+ @scenarios.transform_values do |model|
256
+ scenario_metrics = calculate_metrics(model)
257
+
258
+ result = {}
259
+ @normalized_metrics.each do |metric_config|
260
+ key = metric_config[:key]
261
+ base_value = base_metrics[key] || 0
262
+ scenario_value = scenario_metrics[key] || 0
263
+
264
+ result[key] = {
265
+ absolute: scenario_value - base_value,
266
+ percentage: base_value != 0 ?
267
+ ((scenario_value - base_value) / base_value.to_f * 100).round(2) : 0.0
268
+ }
269
+ end
270
+ result
271
+ end
272
+ end
273
+
274
+ def parse_date(date)
275
+ return date if date.is_a?(Date)
276
+
277
+ case date
278
+ when String
279
+ if date =~ /^\d{4}$/
280
+ Date.new(date.to_i, 1, 1)
281
+ elsif date =~ /^\d{4}-\d{2}$/
282
+ Date.parse("#{date}-01")
283
+ else
284
+ Date.parse(date)
285
+ end
286
+ when Integer
287
+ Date.new(date, 1, 1)
288
+ when Time
289
+ date.to_date
290
+ else
291
+ raise ArgumentError, "Cannot parse date: #{date}"
292
+ end
293
+ end
294
+ end
295
+ end
296
+ end