fin_it 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +24 -0
- data/CHANGELOG.md +9 -0
- data/CONTRIBUTING.md +20 -0
- data/LICENSE +21 -0
- data/QUICKSTART.md +56 -0
- data/README.md +74 -0
- data/Rakefile +23 -0
- data/SECURITY.md +14 -0
- data/assets/fin_it_logo.png +0 -0
- data/lib/fin_it/account.rb +120 -0
- data/lib/fin_it/calculator/currency_conversion.rb +27 -0
- data/lib/fin_it/calculator/date_helpers.rb +53 -0
- data/lib/fin_it/calculator/variable_hashing.rb +120 -0
- data/lib/fin_it/calculator.rb +480 -0
- data/lib/fin_it/categories/category.rb +137 -0
- data/lib/fin_it/complex_model.rb +169 -0
- data/lib/fin_it/dsl/account_builder.rb +35 -0
- data/lib/fin_it/dsl/calculated_builder.rb +87 -0
- data/lib/fin_it/dsl/config_builder.rb +58 -0
- data/lib/fin_it/dsl/model_builder.rb +938 -0
- data/lib/fin_it/dsl/model_template_builder.rb +29 -0
- data/lib/fin_it/dsl/plan_builder.rb +52 -0
- data/lib/fin_it/dsl/project_inheritance_resolver.rb +46 -0
- data/lib/fin_it/dsl/variable_builder.rb +41 -0
- data/lib/fin_it/dsl.rb +13 -0
- data/lib/fin_it/engine.rb +15 -0
- data/lib/fin_it/financial_model/account_balances.rb +99 -0
- data/lib/fin_it/financial_model/account_hierarchy.rb +158 -0
- data/lib/fin_it/financial_model/category_values.rb +179 -0
- data/lib/fin_it/financial_model/currency_helpers.rb +14 -0
- data/lib/fin_it/financial_model/date_helpers.rb +58 -0
- data/lib/fin_it/financial_model/debugging.rb +353 -0
- data/lib/fin_it/financial_model/period_flows.rb +121 -0
- data/lib/fin_it/financial_model/validation.rb +85 -0
- data/lib/fin_it/financial_model/variable_matching.rb +49 -0
- data/lib/fin_it/financial_model.rb +395 -0
- data/lib/fin_it/model_template.rb +121 -0
- data/lib/fin_it/outputs/base_output.rb +51 -0
- data/lib/fin_it/outputs/console_output.rb +1528 -0
- data/lib/fin_it/outputs/monthly_console_output.rb +145 -0
- data/lib/fin_it/outputs/zaxcel_output.rb +1264 -0
- data/lib/fin_it/payment_schedule.rb +112 -0
- data/lib/fin_it/plan.rb +159 -0
- data/lib/fin_it/reports/balance_sheet.rb +638 -0
- data/lib/fin_it/reports/base_report.rb +239 -0
- data/lib/fin_it/reports/cash_flow_statement.rb +480 -0
- data/lib/fin_it/reports/custom_sheet.rb +436 -0
- data/lib/fin_it/reports/income_statement.rb +793 -0
- data/lib/fin_it/reports/period_comparison.rb +309 -0
- data/lib/fin_it/reports/scenario_comparison.rb +296 -0
- data/lib/fin_it/temporal_value.rb +349 -0
- data/lib/fin_it/transaction_generator/account_resolver.rb +118 -0
- data/lib/fin_it/transaction_generator/cache_management.rb +39 -0
- data/lib/fin_it/transaction_generator/date_generation.rb +57 -0
- data/lib/fin_it/transaction_generator.rb +357 -0
- data/lib/fin_it/version.rb +6 -0
- data/lib/fin_it.rb +27 -0
- data/test/fin_it/calculator_test.rb +109 -0
- data/test/fin_it/complex_model_test.rb +198 -0
- data/test/fin_it/debugging_test.rb +112 -0
- data/test/fin_it/driver_variables_test.rb +109 -0
- data/test/fin_it/dsl_test.rb +581 -0
- data/test/fin_it/financial_model_test.rb +196 -0
- data/test/fin_it/frequency_test.rb +51 -0
- data/test/fin_it/outputs/console_output_test.rb +249 -0
- data/test/fin_it/plan_test.rb +281 -0
- data/test/fin_it/reports/account_balance_test.rb +232 -0
- data/test/fin_it/reports/balance_sheet_test.rb +355 -0
- data/test/fin_it/reports/cash_flow_statement_test.rb +234 -0
- data/test/fin_it/reports/custom_sheet_test.rb +246 -0
- data/test/fin_it/reports/income_statement_test.rb +431 -0
- data/test/fin_it/reports/period_comparison_test.rb +226 -0
- data/test/fin_it/reports/restaurant_model_test.rb +225 -0
- data/test/fin_it/reports/scenario_comparison_test.rb +414 -0
- data/test/scripts/generate_demo_reports.rb +47 -0
- data/test/scripts/startup_saas_demo.rb +62 -0
- data/test/test_helper.rb +25 -0
- data/test/verify_accounting_equation.rb +91 -0
- metadata +264 -0
|
@@ -0,0 +1,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
|
+
|
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
|