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,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