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,938 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'variable_builder'
4
+ require_relative 'calculated_builder'
5
+ require_relative 'account_builder'
6
+ require_relative 'config_builder'
7
+ require_relative 'project_inheritance_resolver'
8
+ require_relative 'plan_builder'
9
+ require_relative '../account'
10
+ require_relative '../categories/category'
11
+
12
+ module FinIt
13
+ module DSL
14
+ # DSL for defining financial models
15
+ class ModelBuilder
16
+ attr_reader :calculator, :categories, :accounts
17
+
18
+ def initialize(default_currency: 'USD')
19
+ @calculator = Calculator.new(default_currency: default_currency)
20
+ @categories = []
21
+ @accounts = {}
22
+ @category_accounts = {} # Map category -> account for bidirectional relationship
23
+ @current_category = nil
24
+ @config = { default_currency: default_currency }
25
+ @config[:model_templates] = {}
26
+ @config[:complex_models] = {}
27
+ end
28
+
29
+ # Configuration block
30
+ def config(&block)
31
+ config_builder = ConfigBuilder.new(@config)
32
+ config_builder.instance_eval(&block)
33
+ end
34
+
35
+ # Define an account
36
+ def account(name, &block)
37
+ account_builder = AccountBuilder.new(name)
38
+ account_builder.instance_eval(&block) if block_given?
39
+
40
+ # If inside a category block, inherit type from category if not explicitly set
41
+ inherited_type = nil
42
+ if @current_category
43
+ category_type = @current_category.type
44
+ if [:asset, :liability, :equity].include?(category_type)
45
+ inherited_type = category_type
46
+ end
47
+ end
48
+
49
+ # Use explicit type if provided, otherwise use inherited type
50
+ account_type = account_builder.account_type || inherited_type
51
+
52
+ # Type is required - raise error if neither explicit nor inherited
53
+ unless account_type
54
+ raise ArgumentError, "Account '#{name}' must specify type: or be defined within an asset/liability/equity category"
55
+ end
56
+
57
+ account_obj = Account.new(
58
+ name,
59
+ type: account_type,
60
+ currency: account_builder.account_currency || @config[:default_currency],
61
+ opening_balance: account_builder.account_opening_balance || 0,
62
+ opening_balance_credit_account: account_builder.account_opening_balance_credit_account || :equity
63
+ )
64
+
65
+ @accounts[name] = account_obj
66
+
67
+ # If inside a category block, automatically create a variable that references this account
68
+ if @current_category && [:asset, :liability, :equity].include?(@current_category.type)
69
+ # Create variable with account name directly (no "_balance" suffix)
70
+ var_data = {
71
+ name: name,
72
+ type: :financial,
73
+ frequency: :annual,
74
+ currency: account_obj.currency,
75
+ account: name,
76
+ description: account_obj.name.to_s.humanize
77
+ }
78
+ @current_category.variables << var_data
79
+ end
80
+ end
81
+
82
+ # Define a scenario plan
83
+ # @param name [Symbol] The plan name
84
+ # @param description [String] Optional description
85
+ # @param start_date [Date, String, nil] When plan applies
86
+ # @param end_date [Date, String, nil] When plan ends
87
+ def plan(name, description: nil, start_date: nil, end_date: nil, &block)
88
+ plan_builder = PlanBuilder.new(name, description: description,
89
+ start_date: start_date, end_date: end_date)
90
+ plan_builder.instance_eval(&block) if block_given?
91
+
92
+ @config[:plans] ||= {}
93
+ @config[:plans][name.to_sym] = plan_builder.build
94
+ end
95
+
96
+ # Define a category with type (can be nested)
97
+ # Type is optional for nested categories - inherits from parent
98
+ # default_account: account used by children unless overridden
99
+ # defaults: { frequency:, start_date:, end_date: } inherited by children
100
+ def category(name, type: nil, description: nil, comments: nil, default_account: nil, defaults: nil, &block)
101
+ # Determine parent for inheritance
102
+ parent_category = @current_category
103
+
104
+ # Create category - type inheritance happens in Category constructor
105
+ category_obj = Categories::Category.new(
106
+ name,
107
+ type: type,
108
+ parent: parent_category,
109
+ description: description,
110
+ default_account: default_account,
111
+ defaults: defaults
112
+ )
113
+ category_obj.metadata[:comments] = comments if comments
114
+
115
+ # Add to parent's children or top-level categories
116
+ if parent_category
117
+ parent_category.children << category_obj
118
+ else
119
+ @categories << category_obj
120
+ end
121
+
122
+ # Create account for category (except non-financial categories: driver, metric, assumption)
123
+ non_financial_types = [:driver, :metric, :assumption]
124
+ unless non_financial_types.include?(category_obj.type)
125
+ parent_account = nil
126
+ if @current_category && !non_financial_types.include?(@current_category.type)
127
+ parent_account = get_or_create_category_account(@current_category)
128
+ end
129
+ category_account = get_or_create_category_account(category_obj, parent_account: parent_account)
130
+ @category_accounts[category_obj] = category_account
131
+ end
132
+
133
+ old_category = @current_category
134
+ @current_category = category_obj
135
+
136
+ instance_eval(&block) if block_given?
137
+
138
+ @current_category = old_category
139
+ end
140
+
141
+ # Define driver variables (non-financial assumptions)
142
+ # This is a convenience method that creates a driver category and allows defining variables within it
143
+ def driver(description: nil, &block)
144
+ # Check if driver category already exists
145
+ driver_category = @categories.find { |c| c.name == :drivers && c.parent.nil? }
146
+
147
+ unless driver_category
148
+ driver_category = Categories::Category.new(:drivers, type: :driver, description: description || "Business drivers")
149
+ @categories << driver_category
150
+ end
151
+
152
+ old_category = @current_category
153
+ @current_category = driver_category
154
+
155
+ instance_eval(&block) if block_given?
156
+
157
+ @current_category = old_category
158
+ end
159
+
160
+ # Define a variable with currency support
161
+ def variable(name, type: :financial, frequency: nil, currency: nil, description: nil, unit: nil, account: nil, project: nil, &block)
162
+ # Driver variables can have no currency, financial variables get default currency
163
+ is_driver = @current_category && @current_category.type == :driver
164
+ currency = nil if is_driver
165
+ currency ||= @config[:default_currency] unless is_driver
166
+
167
+ # Apply defaults from category
168
+ category_defaults = @current_category&.defaults || {}
169
+ frequency ||= category_defaults[:frequency] || :annual
170
+ account ||= @current_category&.default_account
171
+
172
+ # Skip account creation for driver variables
173
+ pl_account = nil # P&L account for income/expense tracking
174
+ unless is_driver
175
+ user_specified_account = account # Preserve user's account reference
176
+
177
+ # Auto-create account for variable as sub-account of category's account
178
+ if @current_category && [:income, :expense, :asset, :liability, :equity].include?(@current_category.type)
179
+ category_account = get_or_create_category_account(@current_category)
180
+ variable_account = create_variable_account(name, @current_category.type, category_account, currency)
181
+
182
+ # For income/expense:
183
+ # - account = user's asset account (where money flows to/from, e.g. :checking)
184
+ # If not specified, leave nil so transaction generator uses default asset account
185
+ # - pl_account = auto-created P&L account (for income statement, e.g. :salary)
186
+ # For asset/liability/equity:
187
+ # - account = user's account if specified (e.g. :checking), otherwise the variable account
188
+ # This allows users to define variables that reference existing accounts for balance sheet reporting
189
+ if [:income, :expense].include?(@current_category.type)
190
+ pl_account = variable_account.name # Store P&L account for transaction generation
191
+ # Keep user's account or nil - don't fall back to P&L account
192
+ # Transaction generator will use default asset if nil
193
+ account = user_specified_account
194
+ else
195
+ # For balance sheet categories, preserve user's account reference if specified
196
+ account = user_specified_account || variable_account.name
197
+ end
198
+ elsif account && !@accounts.key?(account.is_a?(Array) ? account.last : account)
199
+ # Legacy: auto-create account if specified but doesn't exist
200
+ if @current_category && [:income, :expense].include?(@current_category.type)
201
+ account = ensure_account_exists(account, @current_category.type)
202
+ else
203
+ raise AccountNotFoundError.new(account)
204
+ end
205
+ end
206
+ end
207
+
208
+ var_builder = VariableBuilder.new(name, @calculator, currency, frequency: frequency, account: account, project: project)
209
+ var_builder.instance_eval(&block) if block_given?
210
+
211
+ var_data = {
212
+ name: name,
213
+ type: type,
214
+ frequency: frequency,
215
+ currency: currency,
216
+ description: description,
217
+ unit: unit,
218
+ account: account,
219
+ pl_account: pl_account, # P&L account for income/expense tracking
220
+ project: project
221
+ }
222
+
223
+ @current_category.variables << var_data if @current_category
224
+ end
225
+
226
+ # Define a calculated variable with formula
227
+ def calculated(name, formula: nil, description: nil, round_to: nil, start_date: nil, end_date: nil, frequency: nil, payment_schedule: nil, debit_account: nil, credit_account: nil, account: nil, &block)
228
+ # Apply defaults from category
229
+ category_defaults = @current_category&.defaults || {}
230
+ account ||= @current_category&.default_account
231
+ frequency ||= category_defaults[:frequency]
232
+ start_date ||= category_defaults[:start_date]
233
+ end_date ||= category_defaults[:end_date]
234
+
235
+ final_formula = nil
236
+ final_frequency = frequency
237
+ final_payment_schedule = payment_schedule
238
+ final_start_date = start_date
239
+ final_end_date = end_date
240
+ final_round_to = round_to
241
+
242
+ # Auto-create account for calculated variable as sub-account of category's account
243
+ # Skip for driver categories
244
+ calculated_account = nil
245
+ if @current_category && @current_category.type != :driver && [:income, :expense, :asset, :liability, :equity].include?(@current_category.type)
246
+ category_account = get_or_create_category_account(@current_category)
247
+ calculated_account = create_variable_account(name, @current_category.type, category_account, @config[:default_currency])
248
+ end
249
+
250
+ # Handle convenience 'account:' parameter - sets debit/credit based on category type
251
+ # For asset/liability/equity categories, store account directly for balance sheet reporting
252
+ # Auto-create accounts if they don't exist (for income/expense categories)
253
+ final_account = nil
254
+ if account && !debit_account && !credit_account && @current_category
255
+ if @current_category.type == :income
256
+ # For income: Debit asset (where money goes), Credit income P&L account
257
+ # - final_debit_account = asset account (user-specified via 'account' param)
258
+ # - final_credit_account = P&L income account (auto-created)
259
+ final_debit_account = account
260
+ final_credit_account = calculated_account ? calculated_account.name : nil
261
+ # Auto-create P&L account if it doesn't exist
262
+ if final_credit_account && !@accounts.key?(final_credit_account.is_a?(Array) ? final_credit_account.last : final_credit_account) && [:income, :expense].include?(@current_category.type)
263
+ final_credit_account = ensure_account_exists(final_credit_account, @current_category.type)
264
+ end
265
+ elsif @current_category.type == :expense
266
+ # For expenses: Debit expense P&L account, Credit asset/liability (where money comes from)
267
+ # - final_debit_account = P&L expense account (auto-created)
268
+ # - final_credit_account = asset/liability account (user-specified via 'account' param)
269
+ final_debit_account = calculated_account ? calculated_account.name : nil
270
+ if final_debit_account && !@accounts.key?(final_debit_account.is_a?(Array) ? final_debit_account.last : final_debit_account) && [:income, :expense].include?(@current_category.type)
271
+ final_debit_account = ensure_account_exists(final_debit_account, @current_category.type)
272
+ end
273
+ # Use user-specified account as credit (asset/liability where money comes from)
274
+ final_credit_account = account
275
+ else
276
+ # For asset/liability/equity categories, use calculated account if created
277
+ final_account = calculated_account ? calculated_account.name : account
278
+ final_debit_account = debit_account
279
+ final_credit_account = credit_account
280
+ end
281
+ else
282
+ # Handle explicit debit_account/credit_account parameters
283
+ # For income: credit_account is asset account, variable's account is income account
284
+ # For expense: debit_account is asset account, variable's account is expense account
285
+ if @current_category
286
+ if @current_category.type == :income
287
+ # Income: Debit asset (credit_account param), Credit income (variable's account)
288
+ if credit_account
289
+ final_debit_account = credit_account # Asset account where money goes
290
+ final_credit_account = calculated_account ? calculated_account.name : find_equity_account # Income account
291
+ elsif debit_account
292
+ final_debit_account = debit_account
293
+ final_credit_account = calculated_account ? calculated_account.name : find_equity_account
294
+ elsif calculated_account
295
+ final_debit_account = find_default_asset_account
296
+ final_credit_account = calculated_account.name
297
+ end
298
+ elsif @current_category.type == :expense
299
+ # Expense: Debit expense (variable's account), Credit asset (debit_account param)
300
+ if debit_account
301
+ final_debit_account = calculated_account ? calculated_account.name : find_equity_account # Expense account
302
+ final_credit_account = debit_account # Asset account where money comes from
303
+ elsif credit_account
304
+ final_debit_account = calculated_account ? calculated_account.name : find_equity_account
305
+ final_credit_account = credit_account
306
+ elsif calculated_account
307
+ final_debit_account = calculated_account.name
308
+ final_credit_account = find_default_asset_account
309
+ end
310
+ end
311
+ end
312
+
313
+ # Auto-create accounts if they don't exist (for asset accounts)
314
+ if final_debit_account && !@accounts.key?(final_debit_account.is_a?(Array) ? final_debit_account.last : final_debit_account)
315
+ if @current_category && [:income, :expense].include?(@current_category.type)
316
+ # Only auto-create if it's an asset account (not income/expense)
317
+ account_obj = @accounts[final_debit_account.is_a?(Array) ? final_debit_account.last : final_debit_account]
318
+ unless account_obj && [:income, :expense].include?(account_obj.type)
319
+ final_debit_account = ensure_account_exists(final_debit_account, @current_category.type)
320
+ end
321
+ end
322
+ end
323
+ if final_credit_account && !@accounts.key?(final_credit_account.is_a?(Array) ? final_credit_account.last : final_credit_account)
324
+ if @current_category && [:income, :expense].include?(@current_category.type)
325
+ # Only auto-create if it's an asset account (not income/expense)
326
+ account_obj = @accounts[final_credit_account.is_a?(Array) ? final_credit_account.last : final_credit_account]
327
+ unless account_obj && [:income, :expense].include?(account_obj.type)
328
+ final_credit_account = ensure_account_exists(final_credit_account, @current_category.type)
329
+ end
330
+ end
331
+ end
332
+ final_account = account if account
333
+ end
334
+
335
+ if formula
336
+ # Inline syntax: calculated(:name, formula: "a + b")
337
+ final_formula = formula
338
+ elsif block_given?
339
+ # Block syntax: calculated(:name) do formula "a + b" end
340
+ calc_builder = CalculatedBuilder.new(name, @calculator)
341
+ calc_builder.instance_eval(&block)
342
+
343
+ # Use block values or parameters, with parameters taking precedence
344
+ final_frequency = frequency || calc_builder.frequency
345
+ final_payment_schedule = payment_schedule || calc_builder.payment_schedule
346
+ final_start_date = start_date || calc_builder.start_date
347
+ final_end_date = end_date || calc_builder.end_date
348
+ final_round_to = round_to || calc_builder.round_to
349
+ final_formula = calc_builder.formula
350
+
351
+ # Handle account from block if not already set from keyword args
352
+ if calc_builder.account && !final_debit_account && !final_credit_account && @current_category
353
+ if @current_category.type == :income
354
+ # Auto-create account if it doesn't exist
355
+ if !@accounts.key?(calc_builder.account.is_a?(Array) ? calc_builder.account.last : calc_builder.account) && [:income, :expense].include?(@current_category.type)
356
+ final_credit_account = ensure_account_exists(calc_builder.account, @current_category.type)
357
+ else
358
+ final_credit_account = calc_builder.account
359
+ end
360
+ elsif @current_category.type == :expense
361
+ # Auto-create account if it doesn't exist
362
+ if !@accounts.key?(calc_builder.account.is_a?(Array) ? calc_builder.account.last : calc_builder.account) && [:income, :expense].include?(@current_category.type)
363
+ final_debit_account = ensure_account_exists(calc_builder.account, @current_category.type)
364
+ else
365
+ final_debit_account = calc_builder.account
366
+ end
367
+ end
368
+ end
369
+
370
+ # Auto-create accounts from builder if they don't exist
371
+ if calc_builder.debit_account && !final_debit_account
372
+ if !@accounts.key?(calc_builder.debit_account.is_a?(Array) ? calc_builder.debit_account.last : calc_builder.debit_account) && @current_category && [:income, :expense].include?(@current_category.type)
373
+ final_debit_account = ensure_account_exists(calc_builder.debit_account, @current_category.type)
374
+ else
375
+ final_debit_account = calc_builder.debit_account
376
+ end
377
+ end
378
+ if calc_builder.credit_account && !final_credit_account
379
+ if !@accounts.key?(calc_builder.credit_account.is_a?(Array) ? calc_builder.credit_account.last : calc_builder.credit_account) && @current_category && [:income, :expense].include?(@current_category.type)
380
+ final_credit_account = ensure_account_exists(calc_builder.credit_account, @current_category.type)
381
+ else
382
+ final_credit_account = calc_builder.credit_account
383
+ end
384
+ end
385
+ end
386
+
387
+ # Auto-inherit project tag from dependencies
388
+ inherited_project = nil
389
+ if final_formula
390
+ dependencies = @calculator.extract_variable_dependencies(final_formula)
391
+ if dependencies.any?
392
+ resolver = ProjectInheritanceResolver.new(@calculator)
393
+ inherited_project = resolver.resolve_project(dependencies)
394
+ end
395
+ end
396
+
397
+ if final_formula
398
+ @calculator.define_calculated(
399
+ name,
400
+ final_formula,
401
+ start_date: final_start_date,
402
+ end_date: final_end_date,
403
+ round_to: final_round_to,
404
+ frequency: final_frequency,
405
+ payment_schedule: final_payment_schedule,
406
+ project: inherited_project
407
+ )
408
+ end
409
+
410
+ var_data = {
411
+ name: name,
412
+ type: :calculated,
413
+ description: description,
414
+ formula: final_formula,
415
+ start_date: final_start_date,
416
+ end_date: final_end_date,
417
+ frequency: final_frequency,
418
+ payment_schedule: final_payment_schedule,
419
+ debit_account: final_debit_account,
420
+ credit_account: final_credit_account,
421
+ account: final_account,
422
+ project: inherited_project
423
+ }
424
+
425
+ @current_category.variables << var_data if @current_category
426
+ end
427
+
428
+ # Define a top-level calculated value (outside categories, can be non-financial)
429
+ def calculated_value(name, formula:, description: nil, round_to: nil, start_date: nil, end_date: nil, frequency: nil, &block)
430
+ # Top-level calculated values don't require accounts
431
+ @calculator.define_calculated(
432
+ name,
433
+ formula,
434
+ start_date: start_date,
435
+ end_date: end_date,
436
+ round_to: round_to,
437
+ frequency: frequency
438
+ )
439
+ end
440
+
441
+ # Define a reusable model template
442
+ def define_model(template_name, &block)
443
+ require_relative '../model_template'
444
+ require_relative 'model_template_builder'
445
+
446
+ template = ModelTemplate.new(template_name)
447
+ builder = ModelTemplateBuilder.new(template)
448
+ builder.instance_eval(&block)
449
+
450
+ # Store template
451
+ @config[:model_templates] ||= {}
452
+ @config[:model_templates][template_name] = template
453
+
454
+ # Create dynamic method for instantiation
455
+ define_singleton_method(template_name) do |instance_name = nil, &instantiation_block|
456
+ instance_name ||= "#{template_name}_#{@config[:complex_models]&.length.to_i + 1}".to_sym
457
+
458
+ # Parse instantiation block parameters
459
+ params = {}
460
+ if instantiation_block
461
+ # Evaluate block in a context that captures keyword arguments
462
+ param_capture = ParamCapture.new
463
+ param_capture.instance_eval(&instantiation_block)
464
+ params = param_capture.params
465
+ end
466
+
467
+ # Instantiate template with accounts reference for auto-creation
468
+ model = template.instantiate(
469
+ instance_name,
470
+ params,
471
+ accounts: @accounts,
472
+ default_currency: @config[:default_currency] || 'USD'
473
+ )
474
+
475
+ # Store complex model instance
476
+ @config[:complex_models] ||= {}
477
+ @config[:complex_models][instance_name] = model
478
+
479
+ model
480
+ end
481
+
482
+ template
483
+ end
484
+
485
+ # Helper class to capture parameters from instantiation block
486
+ class ParamCapture
487
+ attr_reader :params
488
+
489
+ def initialize
490
+ @params = {}
491
+ end
492
+
493
+ def method_missing(name, *args, &block)
494
+ if args.length == 1
495
+ @params[name] = args.first
496
+ elsif args.empty? && block_given?
497
+ @params[name] = block
498
+ else
499
+ @params[name] = args
500
+ end
501
+ end
502
+
503
+ def respond_to_missing?(name, include_private = false)
504
+ true
505
+ end
506
+ end
507
+
508
+ # Build the final model
509
+ def build
510
+ # Set default start_date if not provided (start of current year)
511
+ unless @config[:start_date]
512
+ @config[:start_date] = Date.new(Date.today.year, 1, 1)
513
+ warn "Warning: No start_date specified. Defaulting to start of current year: #{@config[:start_date]}\n#{caller.join("\n")}"
514
+ end
515
+
516
+ # Ensure we have an equity account
517
+ ensure_equity_account
518
+
519
+ # Validate all variables in income/expense categories have required accounts
520
+ validate_category_variables!
521
+
522
+ # Validate and create model
523
+ model = FinancialModel.new(@calculator, @categories, @config, @accounts, @category_accounts)
524
+
525
+ # Validate model
526
+ model.validate!
527
+
528
+ model
529
+ end
530
+
531
+ # Get model templates (for testing/debugging)
532
+ def model_templates
533
+ @config[:model_templates] || {}
534
+ end
535
+
536
+ private
537
+
538
+ def ensure_equity_account
539
+ # Calculate balance needed for equity to balance the sheet
540
+ # All values are stored as positive: Assets = Liabilities + Equity
541
+ total_assets = @accounts.values.select { |acc| acc.type == :asset }
542
+ .sum { |acc| acc.opening_balance.to_f }
543
+ total_liabilities = @accounts.values.select { |acc| acc.type == :liability }
544
+ .sum { |acc| acc.opening_balance.to_f }
545
+
546
+ # Equity = Assets - Liabilities (to balance the sheet)
547
+ equity_balance = total_assets - total_liabilities
548
+
549
+ # Check if the main equity account exists (not just any equity account)
550
+ # Expense accounts are also equity type, but we need the main :equity account
551
+ has_main_equity = @accounts.key?(:equity)
552
+
553
+ # Also check if there's an equity category (by type, not name)
554
+ has_equity_category = find_category_by_type(:equity)
555
+
556
+ if has_main_equity
557
+ # Equity account exists - update its balance
558
+ existing_equity = @accounts[:equity]
559
+ # Always update balance to ensure it's correct (accounts may have been created with 0 balance)
560
+ existing_equity.instance_variable_set(:@opening_balance, equity_balance)
561
+ equity_account = existing_equity
562
+ else
563
+ # Get or create equity category account if equity category exists
564
+ parent_account = nil
565
+ if has_equity_category
566
+ parent_account = get_or_create_category_account(has_equity_category)
567
+ # If category account is :equity, use it and update balance
568
+ if parent_account.name == :equity
569
+ parent_account.instance_variable_set(:@opening_balance, equity_balance)
570
+ @accounts[:equity] = parent_account
571
+ equity_account = parent_account
572
+ else
573
+ # Create default equity account with calculated balance
574
+ equity_account = Account.new(
575
+ :equity,
576
+ type: :equity,
577
+ currency: @config[:default_currency] || 'USD',
578
+ opening_balance: equity_balance,
579
+ parent: parent_account
580
+ )
581
+ @accounts[:equity] = equity_account
582
+ end
583
+ else
584
+ # Create default equity account with calculated balance
585
+ equity_account = Account.new(
586
+ :equity,
587
+ type: :equity,
588
+ currency: @config[:default_currency] || 'USD',
589
+ opening_balance: equity_balance
590
+ )
591
+ @accounts[:equity] = equity_account
592
+ end
593
+
594
+ # Map to equity category if it exists
595
+ if has_equity_category
596
+ @category_accounts[has_equity_category] = equity_account unless @category_accounts[has_equity_category]
597
+ var_data = {
598
+ name: :equity,
599
+ type: :financial,
600
+ frequency: :annual,
601
+ currency: equity_account.currency,
602
+ account: :equity,
603
+ description: "Equity"
604
+ }
605
+ has_equity_category.variables << var_data unless has_equity_category.variables.any? { |v| v[:name] == :equity }
606
+ end
607
+ end
608
+ end
609
+
610
+ def find_category_by_type(type)
611
+ # Recursively search for category with given type
612
+ @categories.each do |category|
613
+ found = find_category_by_type_recursive(category, type)
614
+ return found if found
615
+ end
616
+ nil
617
+ end
618
+
619
+ def find_category_by_type_recursive(category, type)
620
+ return category if category.type == type
621
+
622
+ category.children.each do |child|
623
+ found = find_category_by_type_recursive(child, type)
624
+ return found if found
625
+ end
626
+
627
+ nil
628
+ end
629
+
630
+ def validate_category_variables!
631
+ # Validate that ALL variables in income/expense categories have account mappings
632
+ @categories.each do |category|
633
+ validate_category_variables_recursive(category)
634
+ end
635
+ end
636
+
637
+ def validate_category_variables_recursive(category)
638
+ # Skip driver categories
639
+ return if category.type == :driver
640
+
641
+ # Check variables in this category
642
+ category.variables.each do |var_data|
643
+ # Variables in income/expense categories that generate transactions must have accounts
644
+ # This includes:
645
+ # - Calculated variables (always need accounts for transactions)
646
+ # - Expense variables with periodic frequency (quarterly, monthly) that generate transactions
647
+ # - Income variables: only calculated variables need accounts (regular income vars might be calculation-only)
648
+ # Annual frequency variables might be calculation-only totals, so we don't require accounts for them
649
+ if [:income, :expense].include?(category.type)
650
+ # Check if this variable is meant to generate transactions
651
+ # For expenses: calculated variables and periodic frequency (quarterly, monthly) need accounts
652
+ # For income: only calculated variables need accounts (regular income vars might be calculation-only)
653
+ needs_account = if category.type == :expense
654
+ var_data[:type] == :calculated || [:quarterly, :monthly, :weekly, :daily].include?(var_data[:frequency])
655
+ else # income
656
+ var_data[:type] == :calculated
657
+ end
658
+
659
+ if needs_account
660
+ # Must have at least one account specified
661
+ # For income: credit_account (or account which becomes credit_account)
662
+ # For expense: debit_account (or account which becomes debit_account)
663
+ # The other side will be determined automatically by transaction generator
664
+ has_account = (var_data[:debit_account] || var_data[:credit_account] || var_data[:account])
665
+ unless has_account
666
+ var_type_label = var_data[:type] == :calculated ? "Calculated variable" : "Variable"
667
+ frequency_info = var_data[:frequency] ? " (frequency: #{var_data[:frequency]})" : ""
668
+ raise ArgumentError.new(
669
+ "#{var_type_label} '#{var_data[:name]}' in #{category.type} category#{frequency_info} " \
670
+ "must specify an account to generate transactions. Use 'account:', 'debit_account:', or 'credit_account:' " \
671
+ "when defining the variable."
672
+ )
673
+ end
674
+
675
+ # Auto-create accounts if they don't exist (for income/expense categories)
676
+ if var_data[:debit_account]
677
+ var_data[:debit_account] = ensure_account_exists(var_data[:debit_account], category.type)
678
+ end
679
+ if var_data[:credit_account]
680
+ var_data[:credit_account] = ensure_account_exists(var_data[:credit_account], category.type)
681
+ end
682
+ if var_data[:account]
683
+ var_data[:account] = ensure_account_exists(var_data[:account], category.type)
684
+ end
685
+ end
686
+ end
687
+ end
688
+
689
+ # Recursively check subcategories
690
+ category.children.each do |child|
691
+ validate_category_variables_recursive(child)
692
+ end
693
+ end
694
+
695
+ # Helper to check config values
696
+ def with_bonus?
697
+ @config[:with_bonus] == true
698
+ end
699
+
700
+ # Ensure account exists, creating it if necessary
701
+ # Supports array notation for nested accounts (e.g., [:assets, :checking] or [:liabilities, :accounts_payable])
702
+ # account_ref: Symbol or Array representing the account
703
+ # category_type: The type of category (:income, :expense, etc.) to determine default account type
704
+ def ensure_account_exists(account_ref, category_type)
705
+ # Extract actual account name and determine account type
706
+ if account_ref.is_a?(Array)
707
+ # Validate hierarchy: first element must be a top-level category of type :asset, :liability, or :equity
708
+ top_level_category_name = account_ref.first.to_sym
709
+ top_level_category = @categories.find { |c| c.name == top_level_category_name && c.parent.nil? }
710
+
711
+ unless top_level_category && [:asset, :liability, :equity].include?(top_level_category.type)
712
+ raise ArgumentError, "Account hierarchy must start with a top-level category of type :asset, :liability, or :equity. Got: #{top_level_category_name}"
713
+ end
714
+
715
+ # Extract account name (last element) and determine type from category
716
+ actual_account_name = account_ref.last.to_sym
717
+ account_type = top_level_category.type
718
+ else
719
+ # Symbol notation: default to :asset for income/expense variables
720
+ actual_account_name = account_ref.to_sym
721
+ account_type = :asset # Default for income/expense categories
722
+ end
723
+
724
+ # Check if account already exists
725
+ return actual_account_name if @accounts.key?(actual_account_name)
726
+
727
+ # Create the account
728
+ new_account = Account.new(
729
+ actual_account_name,
730
+ type: account_type,
731
+ currency: @config[:default_currency] || 'USD',
732
+ opening_balance: 0,
733
+ opening_balance_credit_account: :equity
734
+ )
735
+
736
+ @accounts[actual_account_name] = new_account
737
+
738
+ # Add account to appropriate category
739
+ ensure_account_in_category(actual_account_name, account_type, account_ref)
740
+
741
+ actual_account_name
742
+ end
743
+
744
+ # Ensure account is added to appropriate category
745
+ # account_name: Symbol name of the account
746
+ # account_type: Type of account (:asset, :liability, :equity)
747
+ # account_hierarchy: Original account reference (Symbol or Array)
748
+ def ensure_account_in_category(account_name, account_type, account_hierarchy)
749
+ target_category = nil
750
+
751
+ if account_hierarchy.is_a?(Array)
752
+ # Array hierarchy: find or create categories along the path
753
+ # e.g., [:assets, :checking] -> find/create :assets category, add :checking to it
754
+ category_path = account_hierarchy[0..-2] # All elements except the last (account name)
755
+
756
+ if category_path.empty?
757
+ # Just [account_name] - create top-level category
758
+ category_name = account_type == :asset ? :assets : (account_type == :liability ? :liabilities : :equity)
759
+ target_category = find_or_create_category(category_name, account_type)
760
+ else
761
+ # Navigate/create category path
762
+ current_parent = nil
763
+ category_path.each_with_index do |cat_name, idx|
764
+ cat_name = cat_name.to_sym
765
+ # Find or create this category
766
+ if current_parent.nil?
767
+ # Top-level category
768
+ target_category = find_or_create_category(cat_name, account_type, parent: nil)
769
+ else
770
+ # Subcategory
771
+ target_category = find_or_create_category(cat_name, account_type, parent: current_parent)
772
+ end
773
+ current_parent = target_category
774
+ end
775
+ end
776
+ else
777
+ # Symbol notation: find or create default top-level category
778
+ category_name = case account_type
779
+ when :asset
780
+ :assets
781
+ when :liability
782
+ :liabilities
783
+ when :equity
784
+ :equity
785
+ else
786
+ :assets # Default fallback
787
+ end
788
+ target_category = find_or_create_category(category_name, account_type)
789
+ end
790
+
791
+ # Check if account is already in this category
792
+ return if target_category.variables.any? { |v| v[:name] == account_name }
793
+
794
+ # Add account as a variable to the category
795
+ account = @accounts[account_name]
796
+ var_data = {
797
+ name: account_name,
798
+ type: :financial,
799
+ frequency: :annual,
800
+ currency: account.currency,
801
+ account: account_name,
802
+ description: account.name.to_s.humanize
803
+ }
804
+ target_category.variables << var_data
805
+ end
806
+
807
+ # Find or create a category
808
+ def find_or_create_category(name, type, parent: nil)
809
+ name = name.to_sym
810
+
811
+ # Try to find existing category
812
+ if parent.nil?
813
+ # Top-level category
814
+ category = @categories.find { |c| c.name == name && c.parent.nil? }
815
+ else
816
+ # Subcategory
817
+ category = parent.children.find { |c| c.name == name }
818
+ end
819
+
820
+ # Create if not found
821
+ unless category
822
+ category = Categories::Category.new(name, type: type, parent: parent)
823
+ if parent
824
+ parent.children << category
825
+ else
826
+ @categories << category
827
+ end
828
+ end
829
+
830
+ category
831
+ end
832
+
833
+ # Get or create account for a category
834
+ # Returns the account associated with the category
835
+ def get_or_create_category_account(category, parent_account: nil)
836
+ # Return existing account if already mapped
837
+ return @category_accounts[category] if @category_accounts[category]
838
+
839
+ # Determine account type from category type
840
+ account_type = case category.type
841
+ when :income
842
+ :income
843
+ when :expense
844
+ :expense
845
+ when :asset
846
+ :asset
847
+ when :liability
848
+ :liability
849
+ when :equity
850
+ :equity
851
+ else
852
+ raise ArgumentError, "Cannot create account for category type: #{category.type}"
853
+ end
854
+
855
+ # Check if account already exists by name
856
+ account_name = category.name.to_sym
857
+ if @accounts.key?(account_name)
858
+ account = @accounts[account_name]
859
+ # Update parent if needed
860
+ if parent_account && account.parent != parent_account
861
+ account.instance_variable_set(:@parent, parent_account)
862
+ parent_account.children << account unless parent_account.children.include?(account)
863
+ end
864
+ @category_accounts[category] = account
865
+ return account
866
+ end
867
+
868
+ # Create new account for category
869
+ account = Account.new(
870
+ account_name,
871
+ type: account_type,
872
+ currency: @config[:default_currency] || 'USD',
873
+ opening_balance: 0,
874
+ opening_balance_credit_account: :equity,
875
+ parent: parent_account
876
+ )
877
+
878
+ @accounts[account_name] = account
879
+ @category_accounts[category] = account
880
+
881
+ account
882
+ end
883
+
884
+ # Create account for a variable as sub-account of parent account
885
+ def create_variable_account(variable_name, account_type, parent_account, currency)
886
+ account_name = variable_name.to_sym
887
+
888
+ # Check if account already exists
889
+ if @accounts.key?(account_name)
890
+ account = @accounts[account_name]
891
+ # Update parent if needed
892
+ if account.parent != parent_account
893
+ account.instance_variable_set(:@parent, parent_account)
894
+ parent_account.children << account unless parent_account.children.include?(account)
895
+ end
896
+ return account
897
+ end
898
+
899
+ # Create new account for variable
900
+ account = Account.new(
901
+ account_name,
902
+ type: account_type,
903
+ currency: currency || @config[:default_currency] || 'USD',
904
+ opening_balance: 0,
905
+ opening_balance_credit_account: :equity,
906
+ parent: parent_account
907
+ )
908
+
909
+ @accounts[account_name] = account
910
+
911
+ account
912
+ end
913
+
914
+ # Find default asset account or create one
915
+ def find_default_asset_account
916
+ asset_account = @accounts.values.find { |acc| acc.type == :asset }
917
+ return asset_account.name if asset_account
918
+
919
+ # Create default asset account
920
+ default_account = Account.new(
921
+ :default_asset,
922
+ type: :asset,
923
+ currency: @config[:default_currency] || 'USD',
924
+ opening_balance: 0
925
+ )
926
+ @accounts[:default_asset] = default_account
927
+ :default_asset
928
+ end
929
+
930
+ # Find equity account or return :equity
931
+ def find_equity_account
932
+ equity_account = @accounts.values.find { |acc| acc.name == :equity && acc.type == :equity }
933
+ equity_account ? equity_account.name : :equity
934
+ end
935
+ end
936
+ end
937
+ end
938
+