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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ class FinancialModel
5
+ # Date parsing, validation, and period type determination
6
+ module DateHelpers
7
+ # Validate that a date is >= start_date
8
+ def validate_date!(date)
9
+ date = parse_date(date)
10
+ if date < @start_date
11
+ raise StartDateValidationError.new(
12
+ "Date #{date} is before model start_date #{@start_date}. " \
13
+ "All transactions must occur on or after the model start_date."
14
+ )
15
+ end
16
+ date
17
+ end
18
+
19
+ # Determine period type based on date range
20
+ def determine_period_type(start_date, end_date)
21
+ return :annual unless start_date && end_date
22
+
23
+ start_date = parse_date(start_date)
24
+ end_date = parse_date(end_date)
25
+
26
+ days = (end_date - start_date).to_i
27
+ if days <= 1
28
+ :daily
29
+ elsif days <= 7
30
+ :weekly
31
+ elsif days <= 35
32
+ :monthly
33
+ elsif days <= 100
34
+ :quarterly
35
+ else
36
+ :annual
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def parse_date(date)
43
+ return date if date.is_a?(Date)
44
+ return nil if date.nil?
45
+
46
+ case date
47
+ when String
48
+ Date.parse(date)
49
+ when Time
50
+ date.to_date
51
+ else
52
+ date
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ class FinancialModel
5
+ # Debugging and inspection methods for financial models
6
+ module Debugging
7
+ # Debugging: Print transactions for an account within a date range
8
+ def print_account_transactions(account_name, start_date: nil, end_date: nil, io: $stdout)
9
+ account = @accounts[account_name]
10
+ unless account
11
+ io.puts "Account '#{account_name}' not found"
12
+ return
13
+ end
14
+
15
+ start_date ||= @start_date
16
+ end_date ||= Date.today
17
+ start_date = parse_date(start_date)
18
+ end_date = parse_date(end_date)
19
+
20
+ # Generate transactions up to end_date
21
+ generate_transactions(end_date)
22
+
23
+ # Get transactions for this account
24
+ account_txns = transactions(
25
+ date_range: { start: start_date, end: end_date },
26
+ account: account_name
27
+ )
28
+
29
+ io.puts "=" * 100
30
+ io.puts "TRANSACTIONS FOR ACCOUNT: #{account_name.to_s.upcase}"
31
+ io.puts "Account Type: #{account.type.to_s.upcase} | Currency: #{account.currency}"
32
+ io.puts "Period: #{start_date} to #{end_date}"
33
+ io.puts "Opening Balance: #{format_currency(account.opening_balance.to_f, account.currency)}"
34
+ io.puts "=" * 100
35
+
36
+ if account_txns.empty?
37
+ io.puts "No transactions found for this period."
38
+ return
39
+ end
40
+
41
+ # Group by date
42
+ txns_by_date = account_txns.group_by { |t| t[:date] }
43
+
44
+ # Use Money objects for precision
45
+ running_balance = Money.new((account.opening_balance.to_f * 100).to_i, account.currency)
46
+
47
+ io.puts "%-12s %-20s %-30s %15s %15s %15s" % [
48
+ "Date", "Variable", "Description", "Debit", "Credit", "Balance"
49
+ ]
50
+ io.puts "-" * 100
51
+
52
+ txns_by_date.sort.each do |date, txns|
53
+ txns.each do |txn|
54
+ # Convert transaction amount to Money if needed
55
+ txn_amount = txn[:amount]
56
+ if txn_amount.is_a?(Money)
57
+ if txn_amount.currency.iso_code != account.currency
58
+ txn_amount = txn_amount.exchange_to(account.currency)
59
+ end
60
+ else
61
+ txn_amount = Money.new((txn_amount.to_f * 100).to_i, account.currency)
62
+ end
63
+
64
+ debit_amount = txn[:debit_account] == account_name ? txn_amount : Money.new(0, account.currency)
65
+ credit_amount = txn[:credit_account] == account_name ? txn_amount : Money.new(0, account.currency)
66
+
67
+ if account.type == :asset
68
+ if debit_amount.zero? == false
69
+ running_balance += debit_amount
70
+ elsif credit_amount.zero? == false
71
+ running_balance -= credit_amount
72
+ end
73
+ else # liability or equity
74
+ if debit_amount.zero? == false
75
+ running_balance -= debit_amount
76
+ elsif credit_amount.zero? == false
77
+ running_balance += credit_amount
78
+ end
79
+ end
80
+
81
+ # Convert to float for display
82
+ debit_display = debit_amount.zero? ? 0 : debit_amount.to_f
83
+ credit_display = credit_amount.zero? ? 0 : credit_amount.to_f
84
+
85
+ description = txn[:description] || "#{txn[:variable]} (#{txn[:debit_account]} -> #{txn[:credit_account]})"
86
+
87
+ io.puts "%-12s %-20s %-30s %15s %15s %15s" % [
88
+ date.strftime("%Y-%m-%d"),
89
+ txn[:variable].to_s,
90
+ description[0..29],
91
+ debit_display > 0 ? format_currency(debit_display, account.currency) : "",
92
+ credit_display > 0 ? format_currency(credit_display, account.currency) : "",
93
+ format_currency(running_balance.to_f, account.currency)
94
+ ]
95
+ end
96
+ end
97
+
98
+ io.puts "-" * 100
99
+ io.puts "%-62s %15s" % ["Ending Balance (#{end_date}):", format_currency(running_balance.to_f, account.currency)]
100
+ io.puts "=" * 100
101
+ end
102
+
103
+ # Debugging: Print all accounts with balances as of a date, including variable info and hashes
104
+ def print_accounts_summary(as_of_date: nil, include_hashes: true, io: $stdout)
105
+ as_of_date ||= Date.today
106
+ as_of_date = parse_date(as_of_date)
107
+ validate_date!(as_of_date)
108
+
109
+ # Generate transactions up to as_of_date
110
+ generate_transactions(as_of_date)
111
+
112
+ io.puts "=" * 120
113
+ io.puts "ACCOUNT SUMMARY"
114
+ io.puts "As of: #{as_of_date}"
115
+ io.puts "=" * 120
116
+
117
+ # Group accounts by type
118
+ accounts_by_type = @accounts.values.group_by(&:type)
119
+
120
+ [:asset, :liability, :equity].each do |type|
121
+ accounts = accounts_by_type[type] || []
122
+ next if accounts.empty?
123
+
124
+ io.puts "\n#{type.to_s.upcase}S"
125
+ io.puts "-" * 120
126
+ io.puts "%-30s %-10s %20s %20s %20s" % [
127
+ "Account Name", "Currency", "Opening Balance", "Current Balance", "Change"
128
+ ]
129
+ io.puts "-" * 120
130
+
131
+ accounts.each do |account|
132
+ opening = account.opening_balance.to_f
133
+ current = account_balance(account.name, as_of_date: as_of_date)
134
+ change = current - opening
135
+
136
+ io.puts "%-30s %-10s %20s %20s %20s" % [
137
+ account.name.to_s,
138
+ account.currency,
139
+ format_currency(opening, account.currency),
140
+ format_currency(current, account.currency),
141
+ format_currency(change, account.currency)
142
+ ]
143
+ end
144
+
145
+ # Type subtotal
146
+ total_opening = accounts.sum { |a| a.opening_balance.to_f }
147
+ total_current = accounts.sum { |a| account_balance(a.name, as_of_date: as_of_date) }
148
+ total_change = total_current - total_opening
149
+
150
+ io.puts "-" * 120
151
+ io.puts "%-30s %-10s %20s %20s %20s" % [
152
+ "Total #{type.to_s.capitalize}",
153
+ "",
154
+ format_currency(total_opening, @config[:default_currency]),
155
+ format_currency(total_current, @config[:default_currency]),
156
+ format_currency(total_change, @config[:default_currency])
157
+ ]
158
+ end
159
+
160
+ # Balance sheet validation
161
+ total_assets = accounts_by_type[:asset]&.sum { |a| account_balance(a.name, as_of_date: as_of_date) } || 0
162
+ total_liabilities = accounts_by_type[:liability]&.sum { |a| account_balance(a.name, as_of_date: as_of_date) } || 0
163
+ total_equity = accounts_by_type[:equity]&.sum { |a| account_balance(a.name, as_of_date: as_of_date) } || 0
164
+
165
+ io.puts "\n" + "=" * 120
166
+ io.puts "BALANCE SHEET VALIDATION"
167
+ io.puts "-" * 120
168
+ io.puts "Assets: #{format_currency(total_assets, @config[:default_currency])}"
169
+ io.puts "Liabilities: #{format_currency(total_liabilities, @config[:default_currency])}"
170
+ io.puts "Equity: #{format_currency(total_equity, @config[:default_currency])}"
171
+ io.puts "-" * 120
172
+ difference = (total_assets - (total_liabilities + total_equity)).abs
173
+ io.puts "Difference: #{format_currency(difference, @config[:default_currency])}"
174
+ if difference <= 0.01
175
+ io.puts "✓ Balance sheet balances correctly"
176
+ else
177
+ io.puts "✗ Balance sheet does NOT balance!"
178
+ end
179
+
180
+ # Variable information and hashes
181
+ if include_hashes
182
+ io.puts "\n" + "=" * 120
183
+ io.puts "VARIABLE INFORMATION & HASHES"
184
+ io.puts "-" * 120
185
+
186
+ all_hashes = @calculator.all_variable_hashes
187
+
188
+ # Group variables by category
189
+ @categories.each do |category|
190
+ next if category.type == :driver && category.variables.empty?
191
+
192
+ io.puts "\n#{category.name.to_s.upcase} (#{category.type.to_s.upcase})"
193
+ io.puts "-" * 120
194
+
195
+ category.all_variables.each do |var_data|
196
+ var_name = var_data[:name]
197
+ var_hash = all_hashes[var_name]
198
+
199
+ # Get current value
200
+ current_value = begin
201
+ @calculator.calculate(var_name, date: as_of_date)
202
+ rescue => e
203
+ "Error: #{e.message}"
204
+ end
205
+
206
+ value_str = if current_value.is_a?(Money)
207
+ format_currency(current_value.to_f, current_value.currency.iso_code)
208
+ elsif current_value.is_a?(Numeric)
209
+ format_number(current_value)
210
+ else
211
+ current_value.to_s
212
+ end
213
+
214
+ io.puts " Variable: #{var_name}"
215
+ io.puts " Type: #{var_data[:type]}"
216
+ io.puts " Value (#{as_of_date}): #{value_str}"
217
+ io.puts " Hash: #{var_hash}"
218
+
219
+ if var_data[:account]
220
+ io.puts " Account: #{var_data[:account]}"
221
+ end
222
+ if var_data[:debit_account] || var_data[:credit_account]
223
+ io.puts " Debit Account: #{var_data[:debit_account]}" if var_data[:debit_account]
224
+ io.puts " Credit Account: #{var_data[:credit_account]}" if var_data[:credit_account]
225
+ end
226
+ if var_data[:frequency]
227
+ io.puts " Frequency: #{var_data[:frequency]}"
228
+ end
229
+ if var_data[:formula]
230
+ io.puts " Formula: #{var_data[:formula]}"
231
+ end
232
+ io.puts ""
233
+ end
234
+ end
235
+
236
+ # Complex models
237
+ if @complex_models.any?
238
+ io.puts "\nCOMPLEX MODELS"
239
+ io.puts "-" * 120
240
+ @complex_models.each do |name, model|
241
+ model_hash = @calculator.complex_model_hash(model)
242
+ io.puts " Model: #{name}"
243
+ io.puts " Start Date: #{model.start_date}"
244
+ io.puts " End Date: #{model.end_date || 'N/A'}"
245
+ io.puts " Frequency: #{model.frequency}"
246
+ io.puts " Hash: #{model_hash}"
247
+ io.puts ""
248
+ end
249
+ end
250
+ end
251
+
252
+ io.puts "=" * 120
253
+ end
254
+
255
+ # Debugging: Print variable values and hashes
256
+ def print_variables_summary(as_of_date: nil, io: $stdout)
257
+ as_of_date ||= Date.today
258
+ as_of_date = parse_date(as_of_date)
259
+
260
+ io.puts "=" * 100
261
+ io.puts "VARIABLE VALUES & HASHES"
262
+ io.puts "As of: #{as_of_date}"
263
+ io.puts "=" * 100
264
+
265
+ all_hashes = @calculator.all_variable_hashes
266
+
267
+ io.puts "%-30s %-15s %-20s %30s" % [
268
+ "Variable Name", "Type", "Value", "Hash"
269
+ ]
270
+ io.puts "-" * 100
271
+
272
+ @calculator.variable_names.sort.each do |var_name|
273
+ var_hash = all_hashes[var_name]
274
+
275
+ value = begin
276
+ @calculator.calculate(var_name, date: as_of_date)
277
+ rescue => e
278
+ "Error: #{e.message[0..19]}"
279
+ end
280
+
281
+ value_str = if value.is_a?(Money)
282
+ format_currency(value.to_f, value.currency.iso_code)
283
+ elsif value.is_a?(Numeric)
284
+ format_number(value)
285
+ else
286
+ value.to_s[0..19]
287
+ end
288
+
289
+ var_type = @calculator.variables[var_name.to_sym]
290
+ type_str = if var_type.is_a?(Hash)
291
+ var_type[:type] || "unknown"
292
+ else
293
+ "temporal"
294
+ end
295
+
296
+ io.puts "%-30s %-15s %-20s %30s" % [
297
+ var_name.to_s,
298
+ type_str.to_s,
299
+ value_str,
300
+ var_hash.to_s[0..29]
301
+ ]
302
+ end
303
+
304
+ io.puts "=" * 100
305
+ end
306
+
307
+ private
308
+
309
+ def format_currency(value, currency = nil)
310
+ currency ||= @config[:default_currency] || 'USD'
311
+
312
+ # Format number with commas and 2 decimal places
313
+ formatted = if value.is_a?(Money)
314
+ sprintf("%.2f", value.to_f)
315
+ else
316
+ sprintf("%.2f", value.to_f)
317
+ end
318
+
319
+ # Add commas for thousands
320
+ formatted = formatted.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
321
+
322
+ # Add currency symbol
323
+ symbol = case currency.to_s.upcase
324
+ when 'USD' then '$'
325
+ when 'EUR' then '€'
326
+ when 'GBP' then '£'
327
+ when 'JPY' then '¥'
328
+ when 'MXN' then 'MXN$'
329
+ else "#{currency} "
330
+ end
331
+
332
+ "#{symbol}#{formatted}"
333
+ end
334
+
335
+ def format_number(value)
336
+ if value.is_a?(Money)
337
+ sprintf("%.2f", value.to_f)
338
+ elsif value.is_a?(Numeric)
339
+ if value.abs >= 1000
340
+ sprintf("%.2f", value)
341
+ elsif value.abs >= 1
342
+ sprintf("%.2f", value)
343
+ else
344
+ sprintf("%.4f", value)
345
+ end
346
+ else
347
+ value.to_s
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
353
+
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'money'
4
+
5
+ module FinIt
6
+ class FinancialModel
7
+ # Period flow calculations (flows during a period, not cumulative)
8
+ module PeriodFlows
9
+ # Calculate net flow for an account in a period (for income statements)
10
+ # Returns net flow from the account's perspective:
11
+ # - For equity: credits (income) are positive, debits (expenses) are negative
12
+ # - For assets: debits (money in) are positive, credits (money out) are negative
13
+ # - For liabilities: debits (payments) are negative, credits (borrowing) are positive
14
+ # Note: Account balances are always positive, but flows can be positive or negative
15
+ # @param variable [Symbol, nil] Optional variable name to filter transactions by specific variable
16
+ def account_period_flow(account_name, start_date, end_date, variable: nil)
17
+ account = @accounts[account_name]
18
+ return 0 unless account
19
+
20
+ start_date = parse_date(start_date)
21
+ end_date = parse_date(end_date)
22
+ validate_date!(start_date)
23
+ validate_date!(end_date)
24
+
25
+ # Generate transactions up to end_date if not already generated
26
+ @transaction_generator.generate_transactions(end_date)
27
+
28
+ # Get all transactions affecting this account in the period
29
+ # Filter by variable if specified (for income/expense variable-specific flows)
30
+ account_transactions = @transaction_generator.transactions(
31
+ date_range: { start: start_date, end: end_date },
32
+ account: account_name,
33
+ variable: variable
34
+ )
35
+
36
+ # Calculate net flow based on account type using Money objects for precision
37
+ # For income statements, we want:
38
+ # - Equity: credits (income) positive, debits (expenses) negative
39
+ # - Assets: debits (money in) positive, credits (money out) negative
40
+ # - Liabilities: credits (borrowing) positive, debits (payments) negative
41
+ net_flow = Money.new(0, @config[:default_currency])
42
+
43
+ account_transactions.each do |transaction|
44
+ # Convert transaction amount to account currency if needed
45
+ transaction_amount = transaction[:amount]
46
+ if transaction_amount.is_a?(Money)
47
+ # Convert to account currency if different
48
+ if transaction_amount.currency.iso_code != account.currency
49
+ transaction_amount = transaction_amount.exchange_to(account.currency)
50
+ end
51
+ # Convert to default currency for accumulation
52
+ if transaction_amount.currency.iso_code != @config[:default_currency]
53
+ transaction_amount = transaction_amount.exchange_to(@config[:default_currency])
54
+ end
55
+ else
56
+ # Legacy: convert float to Money (shouldn't happen after refactoring)
57
+ transaction_amount = Money.new((transaction_amount.to_f * 100).to_i, @config[:default_currency])
58
+ end
59
+
60
+ if transaction[:debit_account] == account_name
61
+ # Debit to this account
62
+ if account.type == :equity
63
+ net_flow -= transaction_amount # Debit to equity = expense (negative)
64
+ elsif account.type == :asset
65
+ net_flow += transaction_amount # Debit to asset = money in (positive)
66
+ else # liability
67
+ net_flow -= transaction_amount # Debit to liability = payment (negative)
68
+ end
69
+ elsif transaction[:credit_account] == account_name
70
+ # Credit to this account
71
+ if account.type == :equity
72
+ net_flow += transaction_amount # Credit to equity = income (positive)
73
+ elsif account.type == :asset
74
+ net_flow -= transaction_amount # Credit to asset = money out (negative)
75
+ else # liability
76
+ net_flow += transaction_amount # Credit to liability = borrowing (positive)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Convert to account currency if needed
82
+ if account.currency != @config[:default_currency]
83
+ net_flow = net_flow.exchange_to(account.currency)
84
+ end
85
+
86
+ # Return as float for backward compatibility
87
+ net_flow.to_f
88
+ end
89
+
90
+ # Sum period flows for multiple accounts (for income statements)
91
+ def accounts_total_flow(account_names, start_date, end_date, output_currency: nil)
92
+ output_currency ||= @config[:default_currency]
93
+ start_date = parse_date(start_date)
94
+ end_date = parse_date(end_date)
95
+ validate_date!(start_date)
96
+ validate_date!(end_date)
97
+
98
+ total = Money.new(0, output_currency)
99
+
100
+ Array(account_names).each do |account_name|
101
+ account = @accounts[account_name]
102
+ next unless account
103
+
104
+ # Use account period flow
105
+ flow = account_period_flow(account_name, start_date, end_date)
106
+ account_money = Money.new((flow * 100).to_i, account.currency)
107
+
108
+ # Convert to output currency if needed
109
+ if account_money.currency.iso_code != output_currency
110
+ account_money = account_money.exchange_to(output_currency)
111
+ end
112
+
113
+ total += account_money
114
+ end
115
+
116
+ total.to_f
117
+ end
118
+ end
119
+ end
120
+ end
121
+
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ class FinancialModel
5
+ # Model validation logic
6
+ module Validation
7
+ # Validate the model
8
+ def validate!
9
+ validate_start_date!
10
+ validate_formula_dependencies!
11
+ validate_balance_sheet_equation!
12
+ end
13
+
14
+ private
15
+
16
+ def validate_start_date!
17
+ unless @start_date.is_a?(Date)
18
+ raise StartDateValidationError.new(
19
+ "Model start_date must be a Date, got #{@start_date.class}"
20
+ )
21
+ end
22
+ end
23
+
24
+ def validate_formula_dependencies!
25
+ # Get all defined variable names
26
+ defined_vars = @calculator.variable_names.map(&:to_s)
27
+
28
+ # Get variable names that are inside categories (not top-level calculated_values)
29
+ category_variables = @categories.flat_map do |cat|
30
+ collect_category_variable_names(cat)
31
+ end.to_set
32
+
33
+ # Check all calculated variables for undefined dependencies
34
+ # Skip top-level calculated_values (those not in any category)
35
+ @calculator.variables.each do |name, var_def|
36
+ next unless var_def.is_a?(Hash) && var_def[:type] == :calculated
37
+ next unless var_def[:formula]
38
+ next unless category_variables.include?(name.to_sym) || category_variables.include?(name.to_s)
39
+
40
+ dependencies = @calculator.extract_variable_dependencies(var_def[:formula])
41
+ undefined = dependencies.reject { |dep| defined_vars.include?(dep.to_s) }
42
+
43
+ if undefined.any?
44
+ raise UndefinedVariableError.new(
45
+ "Formula for '#{name}' references undefined variable(s): #{undefined.join(', ')}"
46
+ )
47
+ end
48
+ end
49
+ end
50
+
51
+ def collect_category_variable_names(category)
52
+ names = category.variables.map { |v| v[:name] }
53
+ category.children.each do |child|
54
+ names.concat(collect_category_variable_names(child))
55
+ end
56
+ names
57
+ end
58
+
59
+ def validate_balance_sheet_equation!
60
+ # Calculate total assets (all values are stored as positive)
61
+ total_assets = @accounts.values.select { |acc| acc.type == :asset }
62
+ .sum { |acc| acc.opening_balance.to_f }
63
+
64
+ # Calculate total liabilities (all values are stored as positive)
65
+ total_liabilities = @accounts.values.select { |acc| acc.type == :liability }
66
+ .sum { |acc| acc.opening_balance.to_f }
67
+
68
+ # Calculate total equity (all values are stored as positive)
69
+ total_equity = @accounts.values.select { |acc| acc.type == :equity }
70
+ .sum { |acc| acc.opening_balance.to_f }
71
+
72
+ # Validate: Assets = Liabilities + Equity
73
+ difference = (total_assets - (total_liabilities + total_equity)).abs
74
+
75
+ unless difference <= 0.01 # Allow small rounding differences
76
+ raise BalanceSheetValidationError.new(
77
+ "Balance sheet does not balance: Assets (#{total_assets}) != Liabilities (#{total_liabilities}) + Equity (#{total_equity}). " \
78
+ "Difference: #{difference}"
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ class FinancialModel
5
+ # Variable project matching and filtering
6
+ module VariableMatching
7
+ # Check if a variable matches the project filter
8
+ def variable_matches_project?(variable, project_filter)
9
+ return true unless project_filter
10
+
11
+ variable_project = get_variable_project(variable[:name])
12
+ variable_project == project_filter
13
+ end
14
+
15
+ # Get project tag for a variable
16
+ def get_variable_project(variable_name)
17
+ # Check in temporal values metadata
18
+ temporal_value = @calculator.instance_variable_get(:@temporal_values)[variable_name]
19
+ if temporal_value
20
+ # Get project from most recent period metadata
21
+ periods = temporal_value.instance_variable_get(:@periods)
22
+ if periods && periods.any?
23
+ latest_period = periods.last
24
+ return latest_period[:metadata][:project] if latest_period[:metadata]
25
+ end
26
+ end
27
+
28
+ # Check in calculated variables metadata
29
+ var_def = @calculator.variables[variable_name]
30
+ if var_def.is_a?(Hash) && var_def[:type] == :calculated
31
+ return var_def[:project]
32
+ end
33
+
34
+ # Check in complex models
35
+ complex_model = @complex_models[variable_name]
36
+ return complex_model.project if complex_model && complex_model.project
37
+
38
+ # Check in category variables
39
+ @categories.each do |cat|
40
+ var = (cat.variables + cat.descendants.flat_map(&:variables)).find { |v| v[:name] == variable_name }
41
+ return var[:project] if var && var[:project]
42
+ end
43
+
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
49
+