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,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TransactionGenerator - Generates double-entry transactions for financial variables
4
+ #
5
+ # This class handles:
6
+ # - Transaction generation from financial variables
7
+ # - Double-entry accounting compliance
8
+ # - Transaction querying and filtering
9
+ #
10
+ # Concerns extracted to modules:
11
+ # - TransactionGenerator::AccountResolver - Determines debit/credit accounts
12
+ # - TransactionGenerator::DateGeneration - Generates transaction dates from frequency/schedules
13
+ # - TransactionGenerator::CacheManagement - Validates and invalidates transaction cache
14
+
15
+ require 'date'
16
+ require 'money'
17
+
18
+ module FinIt
19
+ # Generates double-entry transactions for financial variables
20
+ class TransactionGenerator
21
+ # TransactionGenerator concerns - loaded explicitly to show dependencies
22
+ require_relative 'transaction_generator/account_resolver'
23
+ require_relative 'transaction_generator/date_generation'
24
+ require_relative 'transaction_generator/cache_management'
25
+
26
+ include TransactionGenerator::AccountResolver
27
+ include TransactionGenerator::DateGeneration
28
+ include TransactionGenerator::CacheManagement
29
+
30
+ attr_reader :model
31
+
32
+ def initialize(model)
33
+ @model = model
34
+ @transactions = []
35
+ @variable_hash_cache = {}
36
+ @max_generated_date = nil
37
+ @opening_balance_transactions_generated = false
38
+ end
39
+
40
+ # Generate all transactions up to end_date
41
+ def generate_transactions(end_date)
42
+ end_date = parse_date(end_date)
43
+ @model.validate_date!(end_date)
44
+
45
+ # Clear existing transactions if variable hashes have changed
46
+ invalidate_cache_if_needed
47
+
48
+ # If we've already generated transactions up to or beyond this date, skip
49
+ if @max_generated_date && end_date <= @max_generated_date && cache_valid?
50
+ return @transactions
51
+ end
52
+
53
+ # Generate opening balance transactions first (on start_date)
54
+ generate_opening_balance_transactions
55
+
56
+ # Generate transactions for all financial variables
57
+ @model.categories.each do |category|
58
+ generate_category_transactions(category, end_date)
59
+ end
60
+
61
+ # Generate transactions for complex models
62
+ @model.complex_models.each do |name, complex_model|
63
+ generate_complex_model_transactions(complex_model, end_date)
64
+ end
65
+
66
+ # Sort transactions by date
67
+ @transactions.sort_by! { |t| t[:date] }
68
+
69
+ # Update max generated date
70
+ @max_generated_date = end_date if !@max_generated_date || end_date > @max_generated_date
71
+
72
+ @transactions
73
+ end
74
+
75
+ # Query transactions
76
+ def transactions(date_range: nil, account: nil, variable: nil)
77
+ result = @transactions.dup
78
+
79
+ # Filter by date range
80
+ if date_range
81
+ start_date = parse_date(date_range[:start] || date_range[:start_date])
82
+ end_date = parse_date(date_range[:end] || date_range[:end_date])
83
+
84
+ result.select! do |t|
85
+ t[:date] >= start_date && t[:date] <= end_date
86
+ end
87
+ end
88
+
89
+ # Filter by account
90
+ if account
91
+ account_sym = account.is_a?(Symbol) ? account : account.to_sym
92
+ result.select! do |t|
93
+ t[:debit_account] == account_sym || t[:credit_account] == account_sym
94
+ end
95
+ end
96
+
97
+ # Filter by variable
98
+ if variable
99
+ var_sym = variable.is_a?(Symbol) ? variable : variable.to_sym
100
+ result.select! { |t| t[:variable] == var_sym }
101
+ end
102
+
103
+ result
104
+ end
105
+
106
+ private
107
+
108
+ def generate_opening_balance_transactions
109
+ # Only generate opening balance transactions once (on first call)
110
+ return if @opening_balance_transactions_generated
111
+
112
+ @model.accounts.each do |account_name, account|
113
+ next if account.opening_balance.to_f == 0
114
+ next if account.type == :equity # Equity changes come from operations
115
+
116
+ # Create opening balance transaction on model start_date
117
+ amount = Money.new((account.opening_balance.to_f * 100).to_i, account.currency)
118
+ amount_value = account.opening_balance.to_f
119
+
120
+ # Determine debit and credit accounts based on account type
121
+ if account.type == :asset
122
+ # Asset opening balance: Debit asset, Credit equity/liability
123
+ debit_account = account_name
124
+ credit_account = ensure_account_exists_for_opening_balance(account.opening_balance_credit_account, amount_value)
125
+ elsif account.type == :liability
126
+ # Liability opening balance: Credit liability, Debit equity
127
+ debit_account = ensure_account_exists_for_opening_balance(account.opening_balance_credit_account, amount_value)
128
+ credit_account = account_name
129
+ else
130
+ next # Skip other types
131
+ end
132
+
133
+ transaction = {
134
+ date: @model.start_date,
135
+ variable: :opening_balance,
136
+ amount: amount,
137
+ debit_account: debit_account,
138
+ credit_account: credit_account,
139
+ description: "Opening balance for #{account_name}",
140
+ currency: account.currency
141
+ }
142
+
143
+ @transactions << transaction
144
+ end
145
+
146
+ @opening_balance_transactions_generated = true
147
+ end
148
+
149
+ def ensure_account_exists_for_opening_balance(account_ref, amount)
150
+ # Handle array notation for hierarchical accounts (e.g., [:equity, :safe_investment])
151
+ if account_ref.is_a?(Array)
152
+ return ensure_account_hierarchy_exists(account_ref, amount)
153
+ end
154
+
155
+ # Handle symbol notation (backward compatible)
156
+ account_name = account_ref.to_sym
157
+
158
+ # Check if account exists - if so, just return the name
159
+ # Do NOT modify existing account balances - the transactions will handle the double-entry
160
+ if @model.accounts.key?(account_name)
161
+ return account_name
162
+ end
163
+
164
+ # Account doesn't exist - determine type and create with amount
165
+ account_type = if account_name.to_s.downcase.include?('liability') || account_name.to_s.downcase.include?('liab')
166
+ :liability
167
+ else
168
+ :equity
169
+ end
170
+
171
+ # Create the account with accumulated balance
172
+ require_relative '../account'
173
+ new_account = Account.new(
174
+ account_name,
175
+ type: account_type,
176
+ currency: @model.config[:default_currency] || 'USD',
177
+ opening_balance: amount,
178
+ opening_balance_credit_account: :equity
179
+ )
180
+
181
+ @model.accounts[account_name] = new_account
182
+ account_name
183
+ end
184
+
185
+ def ensure_account_hierarchy_exists(path_array, amount)
186
+ # Ensure account hierarchy exists and accumulate balance to leaf account
187
+ path_array = path_array.map(&:to_sym)
188
+ return nil if path_array.empty?
189
+
190
+ # Determine account type from first element
191
+ first_element = path_array.first.to_s.downcase
192
+ account_type = if first_element.include?('liability') || first_element.include?('liab')
193
+ :liability
194
+ elsif first_element == 'equity' || first_element.include?('equity')
195
+ :equity
196
+ elsif first_element.include?('asset')
197
+ :asset
198
+ else
199
+ :equity # Default
200
+ end
201
+
202
+ # Check if hierarchy already exists - if so, just return the name
203
+ # Do NOT modify existing account balances - the transactions will handle the double-entry
204
+ existing_account = @model.find_account_by_path(path_array)
205
+ if existing_account
206
+ return existing_account.name
207
+ end
208
+
209
+ # Create hierarchy - start with root account
210
+ require_relative '../account'
211
+ current_account = @model.accounts[path_array.first]
212
+
213
+ if current_account.nil?
214
+ # Create root account with 0 balance (will accumulate later)
215
+ current_account = Account.new(
216
+ path_array.first,
217
+ type: account_type,
218
+ currency: @model.config[:default_currency] || 'USD',
219
+ opening_balance: 0,
220
+ opening_balance_credit_account: :equity
221
+ )
222
+ @model.accounts[path_array.first] = current_account
223
+ end
224
+
225
+ # Traverse/create path for remaining elements
226
+ path_array[1..-1].each_with_index do |account_name, idx|
227
+ # Check if child account exists
228
+ child_account = current_account.children.find { |child| child.name == account_name }
229
+
230
+ if child_account.nil?
231
+ # Create child account
232
+ is_leaf = (idx == path_array.length - 2) # Last element in remaining path
233
+ child_account = Account.new(
234
+ account_name,
235
+ type: account_type,
236
+ currency: @model.config[:default_currency] || 'USD',
237
+ opening_balance: is_leaf ? amount : 0, # Only leaf gets the amount
238
+ opening_balance_credit_account: :equity,
239
+ parent: current_account
240
+ )
241
+ @model.accounts[account_name] = child_account
242
+ current_account.children << child_account
243
+ end
244
+ # Note: We no longer accumulate balance to existing leaf accounts
245
+ # The transactions will handle the double-entry accounting
246
+
247
+ current_account = child_account
248
+ end
249
+
250
+ # Return leaf account name
251
+ path_array.last
252
+ end
253
+
254
+ def generate_category_transactions(category, end_date)
255
+ # Process variables in this category
256
+ category.variables.each do |var_data|
257
+ generate_variable_transactions(var_data, category, end_date)
258
+ end
259
+
260
+ # Process subcategories recursively
261
+ category.children.each do |child|
262
+ generate_category_transactions(child, end_date)
263
+ end
264
+ end
265
+
266
+ def generate_variable_transactions(var_data, category, end_date)
267
+ var_name = var_data[:name]
268
+
269
+ # Skip driver variables (non-financial)
270
+ return if category.type == :driver
271
+
272
+ # Skip if variable doesn't have account mappings
273
+ # For income/expense, we can use default asset account if none specified
274
+ if [:income, :expense].include?(category.type)
275
+ # Income/expense requires either pl_account or explicit accounts
276
+ # pl_account is auto-created for all income/expense variables
277
+ unless var_data[:pl_account] || var_data[:account] || var_data[:debit_account] || var_data[:credit_account]
278
+ return
279
+ end
280
+ else
281
+ # For asset/liability/equity, skip if no account specified
282
+ return unless var_data[:account] || var_data[:debit_account] || var_data[:credit_account]
283
+ end
284
+
285
+ # Determine accounts based on category type
286
+ debit_account, credit_account = determine_accounts(var_data, category)
287
+
288
+ return unless debit_account && credit_account
289
+
290
+ # Get variable frequency and dates
291
+ frequency = var_data[:frequency] || :annual
292
+ start_date = parse_date(var_data[:start_date]) || @model.start_date
293
+ var_end_date = parse_date(var_data[:end_date]) || end_date
294
+
295
+ # Generate transaction dates based on frequency
296
+ transaction_dates = generate_transaction_dates(start_date, [var_end_date, end_date].min, frequency, var_data[:payment_schedule])
297
+
298
+ transaction_dates.each do |date|
299
+ # Calculate variable value for this date
300
+ value = @model.calculator.calculate(
301
+ var_name,
302
+ date: date,
303
+ period_type: frequency == :annual ? :annual : frequency
304
+ )
305
+
306
+ next unless value
307
+
308
+ # Store amount as Money object for precision and currency safety
309
+ # Convert to Money if not already (from calculator, it should be Money)
310
+ amount = if value.is_a?(Money)
311
+ value
312
+ else
313
+ Money.new((value.to_f * 100).to_i, @model.config[:default_currency])
314
+ end
315
+
316
+ # Create transaction
317
+ transaction = {
318
+ date: date,
319
+ variable: var_name,
320
+ amount: amount, # Money object
321
+ debit_account: debit_account,
322
+ credit_account: credit_account,
323
+ description: var_data[:description] || "Transaction for #{var_name}",
324
+ currency: amount.currency.iso_code
325
+ }
326
+
327
+ @transactions << transaction
328
+ end
329
+ end
330
+
331
+ def generate_complex_model_transactions(complex_model, end_date)
332
+ # Build model context with calculator values
333
+ # Keep Money objects for precision, convert to float only if needed by model
334
+ model_context = {}
335
+ @model.calculator.variable_names.each do |var_name|
336
+ # Get value at model start date for context
337
+ value = @model.calculator.calculate(var_name, date: complex_model.start_date)
338
+ if value
339
+ # Pass Money objects to complex models for precision
340
+ # Models can convert to float if needed, but we preserve Money when possible
341
+ model_context[var_name] = value
342
+ end
343
+ end
344
+
345
+ # Calculate transactions from complex model
346
+ model_transactions = complex_model.calculate_transactions(
347
+ end_date,
348
+ @model.calculator,
349
+ model_context
350
+ )
351
+
352
+ @transactions.concat(model_transactions)
353
+ end
354
+
355
+ end
356
+ end
357
+
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ VERSION = "0.1.0"
5
+ end
6
+
data/lib/fin_it.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "money"
4
+ require "bigdecimal"
5
+
6
+ # Set Money rounding mode to avoid warnings
7
+ Money.rounding_mode = BigDecimal::ROUND_HALF_UP
8
+
9
+ require "fin_it/version"
10
+ require "fin_it/engine"
11
+ require "fin_it/account"
12
+ require "fin_it/temporal_value"
13
+ require "fin_it/calculator"
14
+ require "fin_it/plan"
15
+ require "fin_it/dsl"
16
+ require "fin_it/categories/category"
17
+
18
+ # Require reports
19
+ Dir[File.join(__dir__, 'fin_it/reports/*.rb')].each { |file| require file }
20
+
21
+ # Require outputs
22
+ Dir[File.join(__dir__, 'fin_it/outputs/*.rb')].each { |file| require file }
23
+
24
+ module FinIt
25
+ class Error < StandardError; end
26
+ end
27
+
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class CalculatorTest < Minitest::Test
6
+ def setup
7
+ @model = FinIt.define(default_currency: 'USD') do
8
+ config do
9
+ start_date 2024
10
+ end
11
+
12
+ account :checking do
13
+ type :asset
14
+ currency 'USD'
15
+ opening_balance 10_000
16
+ end
17
+
18
+ account :savings do
19
+ type :asset
20
+ currency 'USD'
21
+ opening_balance 50_000
22
+ end
23
+
24
+ category :income, type: :income do
25
+ # Bonus that switches accounts mid-year
26
+ variable :q1_bonus, currency: 'USD', frequency: :annual, account: :checking do
27
+ value 25_000, start_date: "2024-01-01", end_date: "2024-03-31"
28
+ value 0, start_date: "2024-04-01", end_date: "2024-12-31"
29
+ description "Q1 bonus to checking"
30
+ end
31
+
32
+ variable :q4_bonus, currency: 'USD', frequency: :annual, account: :savings do
33
+ value 0, start_date: "2024-01-01", end_date: "2024-09-30"
34
+ value 50_000, start_date: "2024-10-01", end_date: "2024-12-31"
35
+ description "Q4 bonus to savings"
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def test_q1_bonus_in_february
42
+ q1_value = @model.calculator.calculate(:q1_bonus, date: Date.new(2024, 2, 15), period_type: :annual).to_f
43
+ assert_in_delta 25_000, q1_value, 0.01, "Q1 bonus should be $25,000 in February"
44
+ end
45
+
46
+ def test_q1_bonus_in_may
47
+ q2_value = @model.calculator.calculate(:q1_bonus, date: Date.new(2024, 5, 15), period_type: :annual).to_f
48
+ assert_equal 0, q2_value, "Q1 bonus should be $0 in May"
49
+ end
50
+
51
+ def test_q4_bonus_in_november
52
+ q4_bonus = @model.calculator.calculate(:q4_bonus, date: Date.new(2024, 11, 15), period_type: :annual).to_f
53
+ assert_in_delta 50_000, q4_bonus, 0.01, "Q4 bonus should be $50,000 in November"
54
+ end
55
+
56
+ def test_expense_paid_on_specific_date
57
+ expense_model = FinIt.define(default_currency: 'USD') do
58
+ config do
59
+ start_date 2024
60
+ end
61
+
62
+ account :credit_card do
63
+ type :liability
64
+ currency 'USD'
65
+ end
66
+
67
+ category :expenses, type: :expense do
68
+ # Monthly rent paid on the 1st of each month
69
+ variable :rent, currency: 'USD', frequency: :monthly, account: :credit_card do
70
+ value 2_000, start_date: "2024-01-01", end_date: "2024-12-31"
71
+ description "Monthly rent"
72
+ end
73
+
74
+ # Quarterly insurance paid on specific dates
75
+ calculated :insurance,
76
+ formula: "5000",
77
+ frequency: :quarterly,
78
+ payment_schedule: { months: [1, 4, 7, 10], day: 1 },
79
+ start_date: "2024-01-01",
80
+ end_date: "2024-12-31",
81
+ account: :credit_card do
82
+ description "Quarterly insurance premium"
83
+ end
84
+ end
85
+ end
86
+
87
+ # Rent should be available on any date within the period (it's a variable, not calculated with payment schedule)
88
+ rent_feb_1 = expense_model.calculator.calculate(:rent, date: Date.new(2024, 2, 1), period_type: :monthly)
89
+ assert rent_feb_1, "Rent should exist on Feb 1"
90
+ assert_in_delta 2_000, rent_feb_1.to_f, 0.01, "Rent should be $2,000/month"
91
+
92
+ # Insurance should only be paid on quarterly payment dates (Jan 1, Apr 1, Jul 1, Oct 1)
93
+ insurance_jan_1 = expense_model.calculator.calculate(:insurance, date: Date.new(2024, 1, 1), output_currency: 'USD')
94
+ assert insurance_jan_1, "Insurance should be paid on Jan 1 (payment date)"
95
+ assert_in_delta 5_000, insurance_jan_1.to_f, 0.01, "Insurance should be $5,000 on payment date"
96
+
97
+ insurance_apr_1 = expense_model.calculator.calculate(:insurance, date: Date.new(2024, 4, 1), output_currency: 'USD')
98
+ assert insurance_apr_1, "Insurance should be paid on Apr 1 (payment date)"
99
+ assert_in_delta 5_000, insurance_apr_1.to_f, 0.01, "Insurance should be $5,000 on payment date"
100
+
101
+ # Insurance should NOT be paid on non-payment dates
102
+ insurance_feb_15 = expense_model.calculator.calculate(:insurance, date: Date.new(2024, 2, 15), output_currency: 'USD')
103
+ assert_nil insurance_feb_15, "Insurance should be nil on Feb 15 (not a payment date)"
104
+
105
+ insurance_may_15 = expense_model.calculator.calculate(:insurance, date: Date.new(2024, 5, 15), output_currency: 'USD')
106
+ assert_nil insurance_may_15, "Insurance should be nil on May 15 (not a payment date)"
107
+ end
108
+ end
109
+
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class ComplexModelTest < Minitest::Test
6
+ def setup
7
+ @model = FinIt.define(default_currency: 'USD') do
8
+ config do
9
+ start_date 2024
10
+ end
11
+
12
+ account :mortgage do
13
+ type :liability
14
+ currency 'USD'
15
+ opening_balance 300_000 # Positive value
16
+ end
17
+
18
+ account :checking do
19
+ type :asset
20
+ currency 'USD'
21
+ opening_balance 350_000 # 50k initial + 300k borrowed = 350k
22
+ end
23
+
24
+ # Define reusable mortgage payment template
25
+ define_model :mortgage_payment do
26
+ variable :principal
27
+ variable :rate
28
+ variable :term_years
29
+
30
+ default_debit_accounts [:mortgage, :equity]
31
+ default_credit_accounts [:checking]
32
+
33
+ calculation do |date, context|
34
+ # Simple amortization calculation
35
+ principal_balance = context[:principal] || 300_000
36
+ monthly_rate = (context[:rate] || 0.04) / 12.0
37
+ total_payments = (context[:term_years] || 30) * 12
38
+
39
+ # Calculate monthly payment
40
+ if principal_balance > 0
41
+ monthly_payment = principal_balance * (monthly_rate * (1 + monthly_rate)**total_payments) /
42
+ ((1 + monthly_rate)**total_payments - 1)
43
+
44
+ interest = principal_balance * monthly_rate
45
+ principal_portion = monthly_payment - interest
46
+
47
+ # Return multiple transactions: principal + interest
48
+ [
49
+ {
50
+ amount: principal_portion,
51
+ debit_account: :mortgage,
52
+ credit_account: :checking,
53
+ description: "Mortgage principal payment"
54
+ },
55
+ {
56
+ amount: interest,
57
+ debit_account: :equity,
58
+ credit_account: :checking,
59
+ description: "Mortgage interest expense"
60
+ }
61
+ ]
62
+ else
63
+ []
64
+ end
65
+ end
66
+ end
67
+
68
+ # Instantiate mortgage with parameters
69
+ mortgage_payment do
70
+ start_date "2024-01-01"
71
+ end_date "2054-01-01"
72
+ frequency :monthly
73
+ principal 300_000
74
+ rate 0.04
75
+ term_years 30
76
+ debit_account :mortgage
77
+ credit_account :checking
78
+ end
79
+ end
80
+ end
81
+
82
+ def test_template_exists
83
+ # Templates are stored in the builder, but we can't access them easily
84
+ # So we test through the model
85
+ assert @model.complex_models.any?, "Should have complex models"
86
+ end
87
+
88
+ def test_mortgage_generates_transactions
89
+ # Generate transactions for first 3 months
90
+ transactions = @model.generate_transactions(Date.new(2024, 3, 31))
91
+
92
+ # Should have mortgage transactions (principal + interest per month = 6 transactions)
93
+ mortgage_txns = transactions.select { |t| t[:variable].to_s.start_with?('mortgage_payment') }
94
+ assert mortgage_txns.any?, "Should have mortgage transactions"
95
+
96
+ # Should have both principal and interest transactions
97
+ principal_txns = mortgage_txns.select { |t| t[:debit_account] == :mortgage }
98
+ interest_txns = mortgage_txns.select { |t| t[:debit_account] == :equity }
99
+
100
+ assert principal_txns.any?, "Should have principal transactions"
101
+ assert interest_txns.any?, "Should have interest transactions"
102
+ end
103
+
104
+ def test_multiple_transactions_per_period
105
+ transactions = @model.generate_transactions(Date.new(2024, 1, 31))
106
+
107
+ # Get transactions for January 1st
108
+ jan_txns = transactions.select { |t| t[:date] == Date.new(2024, 1, 1) }
109
+ mortgage_jan = jan_txns.select { |t| t[:variable].to_s.start_with?('mortgage_payment') }
110
+
111
+ # Should have 2 transactions (principal + interest) for the same date
112
+ assert mortgage_jan.length >= 2, "Should have multiple transactions per period"
113
+
114
+ # Verify they have different accounts
115
+ accounts = mortgage_jan.map { |t| t[:debit_account] }.uniq
116
+ assert accounts.length >= 2, "Transactions should affect different accounts"
117
+ end
118
+
119
+ def test_double_entry_compliance
120
+ transactions = @model.generate_transactions(Date.new(2024, 3, 31))
121
+ mortgage_txns = transactions.select { |t| t[:variable].to_s.start_with?('mortgage_payment') }
122
+
123
+ # Verify double-entry: each transaction has debit and credit
124
+ mortgage_txns.each do |transaction|
125
+ assert transaction[:debit_account], "Transaction should have debit account"
126
+ assert transaction[:credit_account], "Transaction should have credit account"
127
+ assert transaction[:debit_account] != transaction[:credit_account],
128
+ "Debit and credit accounts should differ"
129
+ end
130
+ end
131
+
132
+ def test_reusable_template
133
+ # Create a second model using the same template with different parameters
134
+ model2 = FinIt.define(default_currency: 'USD') do
135
+ config do
136
+ start_date 2024
137
+ end
138
+
139
+ account :mortgage_2 do
140
+ type :liability
141
+ currency 'USD'
142
+ opening_balance 200_000 # Positive value
143
+ end
144
+
145
+ account :checking_2 do
146
+ type :asset
147
+ currency 'USD'
148
+ opening_balance 230_000 # 30k initial + 200k borrowed = 230k
149
+ end
150
+
151
+ # Same template definition
152
+ define_model :mortgage_payment do
153
+ variable :principal
154
+ variable :rate
155
+ variable :term_years
156
+
157
+ default_debit_accounts [:mortgage, :equity]
158
+ default_credit_accounts [:checking]
159
+
160
+ calculation do |date, context|
161
+ principal_balance = context[:principal] || 200_000
162
+ monthly_rate = (context[:rate] || 0.035) / 12.0
163
+ total_payments = (context[:term_years] || 15) * 12
164
+
165
+ if principal_balance > 0
166
+ monthly_payment = principal_balance * (monthly_rate * (1 + monthly_rate)**total_payments) /
167
+ ((1 + monthly_rate)**total_payments - 1)
168
+ interest = principal_balance * monthly_rate
169
+ principal_portion = monthly_payment - interest
170
+
171
+ [
172
+ { amount: principal_portion, debit_account: :mortgage_2, credit_account: :checking_2 },
173
+ { amount: interest, debit_account: :equity, credit_account: :checking_2 }
174
+ ]
175
+ else
176
+ []
177
+ end
178
+ end
179
+ end
180
+
181
+ # Different parameters
182
+ mortgage_payment do
183
+ start_date "2024-06-01"
184
+ principal 200_000
185
+ rate 0.035
186
+ term_years 15
187
+ debit_account :mortgage_2
188
+ credit_account :checking_2
189
+ end
190
+ end
191
+
192
+ transactions = model2.generate_transactions(Date.new(2024, 6, 30))
193
+ mortgage_txns = transactions.select { |t| t[:variable].to_s.start_with?('mortgage_payment') }
194
+
195
+ assert mortgage_txns.any?, "Second model should generate transactions"
196
+ assert mortgage_txns.first[:debit_account] == :mortgage_2, "Should use specified accounts"
197
+ end
198
+ end