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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +24 -0
- data/CHANGELOG.md +9 -0
- data/CONTRIBUTING.md +20 -0
- data/LICENSE +21 -0
- data/QUICKSTART.md +56 -0
- data/README.md +74 -0
- data/Rakefile +23 -0
- data/SECURITY.md +14 -0
- data/assets/fin_it_logo.png +0 -0
- data/lib/fin_it/account.rb +120 -0
- data/lib/fin_it/calculator/currency_conversion.rb +27 -0
- data/lib/fin_it/calculator/date_helpers.rb +53 -0
- data/lib/fin_it/calculator/variable_hashing.rb +120 -0
- data/lib/fin_it/calculator.rb +480 -0
- data/lib/fin_it/categories/category.rb +137 -0
- data/lib/fin_it/complex_model.rb +169 -0
- data/lib/fin_it/dsl/account_builder.rb +35 -0
- data/lib/fin_it/dsl/calculated_builder.rb +87 -0
- data/lib/fin_it/dsl/config_builder.rb +58 -0
- data/lib/fin_it/dsl/model_builder.rb +938 -0
- data/lib/fin_it/dsl/model_template_builder.rb +29 -0
- data/lib/fin_it/dsl/plan_builder.rb +52 -0
- data/lib/fin_it/dsl/project_inheritance_resolver.rb +46 -0
- data/lib/fin_it/dsl/variable_builder.rb +41 -0
- data/lib/fin_it/dsl.rb +13 -0
- data/lib/fin_it/engine.rb +15 -0
- data/lib/fin_it/financial_model/account_balances.rb +99 -0
- data/lib/fin_it/financial_model/account_hierarchy.rb +158 -0
- data/lib/fin_it/financial_model/category_values.rb +179 -0
- data/lib/fin_it/financial_model/currency_helpers.rb +14 -0
- data/lib/fin_it/financial_model/date_helpers.rb +58 -0
- data/lib/fin_it/financial_model/debugging.rb +353 -0
- data/lib/fin_it/financial_model/period_flows.rb +121 -0
- data/lib/fin_it/financial_model/validation.rb +85 -0
- data/lib/fin_it/financial_model/variable_matching.rb +49 -0
- data/lib/fin_it/financial_model.rb +395 -0
- data/lib/fin_it/model_template.rb +121 -0
- data/lib/fin_it/outputs/base_output.rb +51 -0
- data/lib/fin_it/outputs/console_output.rb +1528 -0
- data/lib/fin_it/outputs/monthly_console_output.rb +145 -0
- data/lib/fin_it/outputs/zaxcel_output.rb +1264 -0
- data/lib/fin_it/payment_schedule.rb +112 -0
- data/lib/fin_it/plan.rb +159 -0
- data/lib/fin_it/reports/balance_sheet.rb +638 -0
- data/lib/fin_it/reports/base_report.rb +239 -0
- data/lib/fin_it/reports/cash_flow_statement.rb +480 -0
- data/lib/fin_it/reports/custom_sheet.rb +436 -0
- data/lib/fin_it/reports/income_statement.rb +793 -0
- data/lib/fin_it/reports/period_comparison.rb +309 -0
- data/lib/fin_it/reports/scenario_comparison.rb +296 -0
- data/lib/fin_it/temporal_value.rb +349 -0
- data/lib/fin_it/transaction_generator/account_resolver.rb +118 -0
- data/lib/fin_it/transaction_generator/cache_management.rb +39 -0
- data/lib/fin_it/transaction_generator/date_generation.rb +57 -0
- data/lib/fin_it/transaction_generator.rb +357 -0
- data/lib/fin_it/version.rb +6 -0
- data/lib/fin_it.rb +27 -0
- data/test/fin_it/calculator_test.rb +109 -0
- data/test/fin_it/complex_model_test.rb +198 -0
- data/test/fin_it/debugging_test.rb +112 -0
- data/test/fin_it/driver_variables_test.rb +109 -0
- data/test/fin_it/dsl_test.rb +581 -0
- data/test/fin_it/financial_model_test.rb +196 -0
- data/test/fin_it/frequency_test.rb +51 -0
- data/test/fin_it/outputs/console_output_test.rb +249 -0
- data/test/fin_it/plan_test.rb +281 -0
- data/test/fin_it/reports/account_balance_test.rb +232 -0
- data/test/fin_it/reports/balance_sheet_test.rb +355 -0
- data/test/fin_it/reports/cash_flow_statement_test.rb +234 -0
- data/test/fin_it/reports/custom_sheet_test.rb +246 -0
- data/test/fin_it/reports/income_statement_test.rb +431 -0
- data/test/fin_it/reports/period_comparison_test.rb +226 -0
- data/test/fin_it/reports/restaurant_model_test.rb +225 -0
- data/test/fin_it/reports/scenario_comparison_test.rb +414 -0
- data/test/scripts/generate_demo_reports.rb +47 -0
- data/test/scripts/startup_saas_demo.rb +62 -0
- data/test/test_helper.rb +25 -0
- data/test/verify_accounting_equation.rb +91 -0
- 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
|