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,480 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Calculator - Pure calculation engine for financial formulas and variables
|
|
4
|
+
#
|
|
5
|
+
# This class handles:
|
|
6
|
+
# - Variable storage and retrieval (drivers, financial, calculated)
|
|
7
|
+
# - Formula evaluation using Dentaku
|
|
8
|
+
# - Temporal value management
|
|
9
|
+
#
|
|
10
|
+
# Concerns extracted to modules:
|
|
11
|
+
# - Calculator::VariableHashing - Variable hash calculation for cache invalidation
|
|
12
|
+
# - Calculator::DateHelpers - Date parsing and period generation
|
|
13
|
+
# - Calculator::CurrencyConversion - Currency conversion and exchange rates
|
|
14
|
+
|
|
15
|
+
require "dentaku"
|
|
16
|
+
require "money"
|
|
17
|
+
require_relative "payment_schedule"
|
|
18
|
+
|
|
19
|
+
module FinIt
|
|
20
|
+
# Calculates values using formulas and temporal values with currency support
|
|
21
|
+
class Calculator
|
|
22
|
+
# Calculator concerns - loaded explicitly to show dependencies
|
|
23
|
+
require_relative "calculator/variable_hashing"
|
|
24
|
+
require_relative "calculator/date_helpers"
|
|
25
|
+
require_relative "calculator/currency_conversion"
|
|
26
|
+
|
|
27
|
+
include Calculator::VariableHashing
|
|
28
|
+
include Calculator::DateHelpers
|
|
29
|
+
include Calculator::CurrencyConversion
|
|
30
|
+
attr_reader :variables, :calculator, :default_currency
|
|
31
|
+
|
|
32
|
+
def initialize(default_currency: 'USD')
|
|
33
|
+
@variables = {}
|
|
34
|
+
@calculator = Dentaku::Calculator.new
|
|
35
|
+
@temporal_values = {}
|
|
36
|
+
@default_currency = default_currency
|
|
37
|
+
|
|
38
|
+
# Set up exchange rates (in production, use real rates)
|
|
39
|
+
configure_exchange_rates
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Define a simple variable with a value and currency
|
|
43
|
+
def define_variable(name, value, currency: nil, frequency: :annual, start_date: nil, end_date: nil, metadata: {}, account: nil, project: nil)
|
|
44
|
+
# Driver variables (no currency) are stored as-is without frequency conversion
|
|
45
|
+
is_driver = currency.nil?
|
|
46
|
+
currency ||= @default_currency if !is_driver
|
|
47
|
+
|
|
48
|
+
# Convert value based on frequency to annual amount for financial variables
|
|
49
|
+
# Driver variables are not scaled by frequency
|
|
50
|
+
annualized_value = if is_driver
|
|
51
|
+
value # Drivers like employee count don't get scaled
|
|
52
|
+
else
|
|
53
|
+
case frequency
|
|
54
|
+
when :daily
|
|
55
|
+
value * 365
|
|
56
|
+
when :weekly
|
|
57
|
+
value * 52
|
|
58
|
+
when :monthly
|
|
59
|
+
value * 12
|
|
60
|
+
when :quarterly
|
|
61
|
+
value * 4
|
|
62
|
+
when :annual
|
|
63
|
+
value
|
|
64
|
+
else
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Add frequency, account, and project to metadata
|
|
70
|
+
metadata = metadata.merge(frequency: frequency, account: account, is_driver: is_driver, project: project)
|
|
71
|
+
|
|
72
|
+
@temporal_values[name] ||= TemporalValue.new(name, default_currency: currency)
|
|
73
|
+
@temporal_values[name].add_period(annualized_value,
|
|
74
|
+
start_date: start_date,
|
|
75
|
+
end_date: end_date,
|
|
76
|
+
currency: is_driver ? nil : currency,
|
|
77
|
+
metadata: metadata
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Store appropriately - Money for financial variables, raw value for drivers
|
|
81
|
+
@variables[name] = if is_driver
|
|
82
|
+
annualized_value
|
|
83
|
+
elsif annualized_value.is_a?(Money)
|
|
84
|
+
annualized_value
|
|
85
|
+
else
|
|
86
|
+
Money.new((annualized_value * 100).to_i, currency)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Define a calculated variable with a formula
|
|
91
|
+
def define_calculated(name, formula, start_date: nil, end_date: nil, dependencies: [], round_to: nil, frequency: nil, payment_schedule: nil, project: nil)
|
|
92
|
+
# Create payment schedule if frequency is specified
|
|
93
|
+
schedule = nil
|
|
94
|
+
if frequency && payment_schedule
|
|
95
|
+
schedule = PaymentSchedule.new(
|
|
96
|
+
frequency: frequency,
|
|
97
|
+
payment_schedule: payment_schedule,
|
|
98
|
+
start_date: start_date,
|
|
99
|
+
end_date: end_date
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@variables[name] = {
|
|
104
|
+
type: :calculated,
|
|
105
|
+
formula: formula,
|
|
106
|
+
start_date: start_date,
|
|
107
|
+
end_date: end_date,
|
|
108
|
+
dependencies: dependencies,
|
|
109
|
+
round_to: round_to,
|
|
110
|
+
frequency: frequency,
|
|
111
|
+
payment_schedule: schedule,
|
|
112
|
+
project: project
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Calculate a variable's value at a specific date with currency conversion
|
|
117
|
+
def calculate(name, date: nil, output_currency: nil, context: {}, period_type: :annual)
|
|
118
|
+
output_currency ||= @default_currency
|
|
119
|
+
date = parse_date(date) if date
|
|
120
|
+
|
|
121
|
+
# Check if variable has temporal values
|
|
122
|
+
if @temporal_values[name]
|
|
123
|
+
return @temporal_values[name].value_at(date || Date.today, output_currency: output_currency, period_type: period_type)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Check if it's a calculated variable
|
|
127
|
+
var_def = @variables[name]
|
|
128
|
+
|
|
129
|
+
if var_def.is_a?(Hash) && var_def[:type] == :calculated
|
|
130
|
+
# Check if this calculation is valid for the given date
|
|
131
|
+
if date
|
|
132
|
+
start_ok = var_def[:start_date].nil? || parse_date(var_def[:start_date]) <= date
|
|
133
|
+
end_ok = var_def[:end_date].nil? || date <= parse_date(var_def[:end_date])
|
|
134
|
+
|
|
135
|
+
return nil unless start_ok && end_ok
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# If payment schedule exists, check if this is a payment date
|
|
139
|
+
if var_def[:payment_schedule]
|
|
140
|
+
return nil unless var_def[:payment_schedule].payment_date?(date || Date.today)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Build context with all values in the output currency
|
|
144
|
+
calc_context = build_currency_context(date, output_currency, period_type: period_type)
|
|
145
|
+
calc_context.merge!(context)
|
|
146
|
+
|
|
147
|
+
# Evaluate formula
|
|
148
|
+
result = @calculator.evaluate(var_def[:formula], calc_context)
|
|
149
|
+
|
|
150
|
+
# Convert result to Money
|
|
151
|
+
result_money = Money.new((result * 100).to_i, output_currency) if result
|
|
152
|
+
|
|
153
|
+
# Round if specified
|
|
154
|
+
if var_def[:round_to] && result_money
|
|
155
|
+
fractional = result_money.fractional
|
|
156
|
+
rounded = fractional.round(-(2 - var_def[:round_to]))
|
|
157
|
+
result_money = Money.new(rounded, output_currency)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
result_money
|
|
161
|
+
elsif var_def.is_a?(Money)
|
|
162
|
+
# Simple variable - convert if needed
|
|
163
|
+
var_def.exchange_to(output_currency)
|
|
164
|
+
else
|
|
165
|
+
var_def
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Calculate all values for a date range
|
|
170
|
+
def calculate_range(start_date, end_date, frequency: :monthly)
|
|
171
|
+
start_date = parse_date(start_date)
|
|
172
|
+
end_date = parse_date(end_date)
|
|
173
|
+
|
|
174
|
+
results = {}
|
|
175
|
+
|
|
176
|
+
# Generate dates based on frequency
|
|
177
|
+
dates = generate_dates(start_date, end_date, frequency)
|
|
178
|
+
|
|
179
|
+
dates.each do |date|
|
|
180
|
+
results[date] = {}
|
|
181
|
+
|
|
182
|
+
@variables.keys.each do |var_name|
|
|
183
|
+
results[date][var_name] = calculate(var_name, date: date)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Also include temporal values
|
|
187
|
+
@temporal_values.keys.each do |var_name|
|
|
188
|
+
results[date][var_name] ||= @temporal_values[var_name].value_at(date)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
results
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Get variable value at a specific date (convenience method)
|
|
196
|
+
def get(name, date: nil)
|
|
197
|
+
calculate(name, date: date)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Get all variable names
|
|
201
|
+
def variable_names
|
|
202
|
+
(@variables.keys + @temporal_values.keys).uniq
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Extract variable names from a formula string
|
|
206
|
+
def extract_variable_dependencies(formula)
|
|
207
|
+
return [] unless formula.is_a?(String)
|
|
208
|
+
|
|
209
|
+
# Use Dentaku to tokenize and extract identifiers
|
|
210
|
+
tokens = @calculator.tokenize(formula)
|
|
211
|
+
tokens.select { |token| token.is_a?(Dentaku::Token::Identifier) }.map(&:value).uniq
|
|
212
|
+
rescue
|
|
213
|
+
# Fallback: simple regex extraction
|
|
214
|
+
formula.scan(/\b[a-z_][a-z0-9_]*\b/i).uniq
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Get project tag for a variable
|
|
218
|
+
def get_variable_project(variable_name)
|
|
219
|
+
# Normalize to symbol for lookup
|
|
220
|
+
var_key = variable_name.is_a?(Symbol) ? variable_name : variable_name.to_sym
|
|
221
|
+
|
|
222
|
+
# Check in temporal values metadata
|
|
223
|
+
temporal_value = @temporal_values[var_key] || @temporal_values[variable_name]
|
|
224
|
+
if temporal_value
|
|
225
|
+
periods = temporal_value.instance_variable_get(:@periods)
|
|
226
|
+
if periods && periods.any?
|
|
227
|
+
latest_period = periods.last
|
|
228
|
+
return latest_period[:metadata][:project] if latest_period[:metadata]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Check in calculated variables metadata
|
|
233
|
+
var_def = @variables[var_key] || @variables[variable_name]
|
|
234
|
+
if var_def.is_a?(Hash) && var_def[:type] == :calculated
|
|
235
|
+
return var_def[:project]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_currency_context(date, output_currency, period_type: :annual)
|
|
242
|
+
context = {}
|
|
243
|
+
|
|
244
|
+
# Convert all values to output currency for calculation
|
|
245
|
+
@temporal_values.each do |name, temporal|
|
|
246
|
+
value = temporal.value_at(date || Date.today, output_currency: output_currency, period_type: period_type)
|
|
247
|
+
if value
|
|
248
|
+
if value.is_a?(Money)
|
|
249
|
+
# Convert to output currency and get numeric value
|
|
250
|
+
converted = value.exchange_to(output_currency)
|
|
251
|
+
context[name] = converted.to_f
|
|
252
|
+
else
|
|
253
|
+
# Driver variable - use raw numeric value
|
|
254
|
+
context[name] = value
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
@variables.each do |name, value|
|
|
260
|
+
next if value.is_a?(Hash) # Skip calculated - will be resolved recursively
|
|
261
|
+
next if @temporal_values.key?(name) # Skip if already added from temporal values
|
|
262
|
+
|
|
263
|
+
if value.is_a?(Money)
|
|
264
|
+
converted = value.exchange_to(output_currency)
|
|
265
|
+
context[name] = converted.to_f
|
|
266
|
+
else
|
|
267
|
+
# Driver variable
|
|
268
|
+
context[name] = value
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Recursively resolve calculated variables
|
|
273
|
+
max_iterations = 20
|
|
274
|
+
iterations = 0
|
|
275
|
+
|
|
276
|
+
loop do
|
|
277
|
+
iterations += 1
|
|
278
|
+
break if iterations > max_iterations
|
|
279
|
+
|
|
280
|
+
changed = false
|
|
281
|
+
|
|
282
|
+
@variables.each do |name, value|
|
|
283
|
+
next unless value.is_a?(Hash) && value[:type] == :calculated
|
|
284
|
+
next if context[name] # Already calculated
|
|
285
|
+
|
|
286
|
+
# Check if this calculation is valid for the given date
|
|
287
|
+
if date
|
|
288
|
+
start_ok = value[:start_date].nil? || parse_date(value[:start_date]) <= date
|
|
289
|
+
end_ok = value[:end_date].nil? || date <= parse_date(value[:end_date])
|
|
290
|
+
next unless start_ok && end_ok
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# If payment schedule exists, check if this is a payment date
|
|
294
|
+
if value[:payment_schedule]
|
|
295
|
+
next unless value[:payment_schedule].payment_date?(date || Date.today)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Check if we can calculate this formula now
|
|
299
|
+
begin
|
|
300
|
+
result = @calculator.evaluate(value[:formula], context)
|
|
301
|
+
if result
|
|
302
|
+
# Convert result to Money and then to float for context
|
|
303
|
+
result_money = Money.new((result * 100).to_i, output_currency)
|
|
304
|
+
context[name] = result_money.to_f
|
|
305
|
+
changed = true
|
|
306
|
+
end
|
|
307
|
+
rescue
|
|
308
|
+
# Can't calculate yet, missing dependencies
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
break unless changed
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
context
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def build_context(date)
|
|
319
|
+
context = {}
|
|
320
|
+
|
|
321
|
+
# Add all simple variables
|
|
322
|
+
@variables.each do |name, value|
|
|
323
|
+
next if value.is_a?(Hash) # Skip calculated variables
|
|
324
|
+
context[name] = value.is_a?(Money) ? value.to_f : value
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Add temporal values at this date
|
|
328
|
+
@temporal_values.each do |name, temporal|
|
|
329
|
+
value = temporal.value_at(date || Date.today)
|
|
330
|
+
context[name] = value.is_a?(Money) ? value.to_f : value
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Recursively resolve calculated variables
|
|
334
|
+
max_iterations = 10
|
|
335
|
+
iterations = 0
|
|
336
|
+
|
|
337
|
+
loop do
|
|
338
|
+
iterations += 1
|
|
339
|
+
break if iterations > max_iterations
|
|
340
|
+
|
|
341
|
+
changed = false
|
|
342
|
+
|
|
343
|
+
@variables.each do |name, value|
|
|
344
|
+
next unless value.is_a?(Hash) && value[:type] == :calculated
|
|
345
|
+
next if context[name] # Already calculated
|
|
346
|
+
|
|
347
|
+
# Check if we can calculate this formula now
|
|
348
|
+
begin
|
|
349
|
+
result = @calculator.evaluate(value[:formula], context)
|
|
350
|
+
result = result.round(value[:round_to]) if value[:round_to] && result
|
|
351
|
+
context[name] = result
|
|
352
|
+
changed = true
|
|
353
|
+
rescue
|
|
354
|
+
# Can't calculate yet, missing dependencies
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
break unless changed
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
context
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Deep clone this calculator for model isolation
|
|
365
|
+
def deep_clone
|
|
366
|
+
cloned = Calculator.new(default_currency: @default_currency)
|
|
367
|
+
|
|
368
|
+
# Clone temporal values
|
|
369
|
+
@temporal_values.each do |name, temporal_value|
|
|
370
|
+
cloned.instance_variable_get(:@temporal_values)[name] = temporal_value.deep_clone
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Clone variable definitions
|
|
374
|
+
@variables.each do |name, var_def|
|
|
375
|
+
cloned.instance_variable_get(:@variables)[name] = deep_clone_variable_def(var_def)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
cloned
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Override methods for plan application
|
|
382
|
+
|
|
383
|
+
# Replace value for a variable
|
|
384
|
+
def set_override(name, value, start_date: nil, end_date: nil)
|
|
385
|
+
name = name.to_sym
|
|
386
|
+
temporal = @temporal_values[name]
|
|
387
|
+
if temporal
|
|
388
|
+
temporal.set_period(value, start_date: start_date, end_date: end_date)
|
|
389
|
+
else
|
|
390
|
+
# Simple variable - just replace
|
|
391
|
+
@variables[name] = convert_to_money(value)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Scale variable by factor
|
|
396
|
+
def scale_variable(name, factor, start_date: nil, end_date: nil)
|
|
397
|
+
name = name.to_sym
|
|
398
|
+
temporal = @temporal_values[name]
|
|
399
|
+
if temporal
|
|
400
|
+
temporal.scale_periods(factor, start_date: start_date, end_date: end_date)
|
|
401
|
+
else
|
|
402
|
+
current = @variables[name]
|
|
403
|
+
if current.is_a?(Money)
|
|
404
|
+
@variables[name] = Money.new((current.fractional * factor).round, current.currency)
|
|
405
|
+
elsif current.is_a?(Numeric)
|
|
406
|
+
@variables[name] = current * factor
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Add/subtract amount
|
|
412
|
+
def adjust_variable(name, amount, start_date: nil, end_date: nil)
|
|
413
|
+
name = name.to_sym
|
|
414
|
+
temporal = @temporal_values[name]
|
|
415
|
+
if temporal
|
|
416
|
+
temporal.adjust_periods(amount, start_date: start_date, end_date: end_date)
|
|
417
|
+
else
|
|
418
|
+
current = @variables[name]
|
|
419
|
+
if current.is_a?(Money)
|
|
420
|
+
@variables[name] = Money.new(current.fractional + (amount * 100).to_i, current.currency)
|
|
421
|
+
elsif current.is_a?(Numeric)
|
|
422
|
+
@variables[name] = current + amount
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Override calculated variable formula
|
|
428
|
+
def override_formula(name, new_formula, start_date: nil, end_date: nil)
|
|
429
|
+
name = name.to_sym
|
|
430
|
+
var_def = @variables[name]
|
|
431
|
+
return unless var_def.is_a?(Hash) && var_def[:type] == :calculated
|
|
432
|
+
|
|
433
|
+
if start_date || end_date
|
|
434
|
+
# Temporal formula override - store both
|
|
435
|
+
var_def[:formula_overrides] ||= []
|
|
436
|
+
var_def[:formula_overrides] << {
|
|
437
|
+
formula: new_formula,
|
|
438
|
+
start_date: parse_date(start_date),
|
|
439
|
+
end_date: parse_date(end_date)
|
|
440
|
+
}
|
|
441
|
+
else
|
|
442
|
+
# Full replacement
|
|
443
|
+
var_def[:formula] = new_formula
|
|
444
|
+
var_def[:dependencies] = extract_variable_dependencies(new_formula)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
private
|
|
449
|
+
|
|
450
|
+
def deep_clone_variable_def(var_def)
|
|
451
|
+
case var_def
|
|
452
|
+
when Hash
|
|
453
|
+
cloned = {}
|
|
454
|
+
var_def.each do |k, v|
|
|
455
|
+
cloned[k] = case v
|
|
456
|
+
when Array then v.map { |item| item.is_a?(Hash) ? item.dup : item }
|
|
457
|
+
when Hash then v.dup
|
|
458
|
+
when PaymentSchedule then v.dup
|
|
459
|
+
else v
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
cloned
|
|
463
|
+
when Money
|
|
464
|
+
Money.new(var_def.fractional, var_def.currency)
|
|
465
|
+
else
|
|
466
|
+
var_def
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def convert_to_money(value)
|
|
471
|
+
if value.is_a?(Money)
|
|
472
|
+
value
|
|
473
|
+
else
|
|
474
|
+
Money.new((value * 100).to_i, @default_currency)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module Categories
|
|
5
|
+
class Category
|
|
6
|
+
FINANCIAL_TYPES = [
|
|
7
|
+
:income, # Revenue, sales
|
|
8
|
+
:expense, # Operating expenses, COGS
|
|
9
|
+
:asset, # Current/fixed assets
|
|
10
|
+
:liability, # Debts, obligations
|
|
11
|
+
:equity, # Owner's equity
|
|
12
|
+
:cash_inflow, # Operating/investing/financing inflows
|
|
13
|
+
:cash_outflow # Operating/investing/financing outflows
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
NON_FINANCIAL_TYPES = [
|
|
17
|
+
:metric, # KPIs, ratios
|
|
18
|
+
:driver, # Employee count, units sold
|
|
19
|
+
:assumption # Growth rates, factors
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_accessor :name, :type, :parent, :children, :variables, :description, :metadata,
|
|
23
|
+
:default_account, :defaults
|
|
24
|
+
|
|
25
|
+
def initialize(name, type: nil, parent: nil, description: nil, default_account: nil, defaults: nil)
|
|
26
|
+
@name = name
|
|
27
|
+
@parent = parent
|
|
28
|
+
@children = []
|
|
29
|
+
@variables = []
|
|
30
|
+
@description = description
|
|
31
|
+
@metadata = {}
|
|
32
|
+
|
|
33
|
+
# Type can be inherited from parent if not specified
|
|
34
|
+
resolved_type = type || parent&.type
|
|
35
|
+
unless resolved_type
|
|
36
|
+
raise ArgumentError, "Category '#{name}' must specify type: or be nested inside a parent category"
|
|
37
|
+
end
|
|
38
|
+
@type = validate_type(resolved_type)
|
|
39
|
+
|
|
40
|
+
# Default account - inherit from parent if not specified
|
|
41
|
+
@default_account = default_account || parent&.default_account
|
|
42
|
+
|
|
43
|
+
# Defaults hash (frequency, start_date, end_date) - merge with parent's defaults
|
|
44
|
+
parent_defaults = parent&.defaults || {}
|
|
45
|
+
@defaults = parent_defaults.merge(defaults || {})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def financial?
|
|
49
|
+
FINANCIAL_TYPES.include?(@type)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def non_financial?
|
|
53
|
+
NON_FINANCIAL_TYPES.include?(@type)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get all descendant categories recursively
|
|
57
|
+
def descendants
|
|
58
|
+
children + children.flat_map(&:descendants)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get all variables including from descendants
|
|
62
|
+
def all_variables
|
|
63
|
+
variables + descendants.flat_map(&:variables)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Calculate total for this category and descendants
|
|
67
|
+
def total(date: nil, calculator: nil, output_currency: nil)
|
|
68
|
+
return 0 unless calculator
|
|
69
|
+
|
|
70
|
+
all_variables.sum do |var|
|
|
71
|
+
value = calculator.calculate(var[:name], date: date, output_currency: output_currency)
|
|
72
|
+
|
|
73
|
+
# Handle Money objects
|
|
74
|
+
if value.respond_to?(:to_f)
|
|
75
|
+
value.to_f
|
|
76
|
+
else
|
|
77
|
+
value || 0
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get the full path from root to this category
|
|
83
|
+
def path
|
|
84
|
+
if parent
|
|
85
|
+
parent.path + [name]
|
|
86
|
+
else
|
|
87
|
+
[name]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Find a subcategory by name (recursive)
|
|
92
|
+
def find_subcategory(name)
|
|
93
|
+
return self if self.name == name
|
|
94
|
+
|
|
95
|
+
children.each do |child|
|
|
96
|
+
found = child.find_subcategory(name)
|
|
97
|
+
return found if found
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Deep clone this category and all descendants
|
|
104
|
+
def deep_clone(parent: nil)
|
|
105
|
+
cloned = Category.new(
|
|
106
|
+
@name,
|
|
107
|
+
type: @type,
|
|
108
|
+
parent: parent,
|
|
109
|
+
description: @description,
|
|
110
|
+
default_account: @default_account,
|
|
111
|
+
defaults: @defaults.dup
|
|
112
|
+
)
|
|
113
|
+
cloned.metadata = @metadata.dup
|
|
114
|
+
|
|
115
|
+
# Clone variables array (deep copy each hash)
|
|
116
|
+
cloned.variables = @variables.map { |v| v.dup }
|
|
117
|
+
|
|
118
|
+
# Recursively clone children
|
|
119
|
+
@children.each do |child|
|
|
120
|
+
cloned_child = child.deep_clone(parent: cloned)
|
|
121
|
+
cloned.children << cloned_child
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
cloned
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def validate_type(type)
|
|
130
|
+
unless FINANCIAL_TYPES.include?(type) || NON_FINANCIAL_TYPES.include?(type)
|
|
131
|
+
raise ArgumentError, "Invalid category type: #{type}. Must be one of: #{(FINANCIAL_TYPES + NON_FINANCIAL_TYPES).join(', ')}"
|
|
132
|
+
end
|
|
133
|
+
type
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|