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