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,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'money'
|
|
4
|
+
require_relative 'transaction_generator'
|
|
5
|
+
require_relative 'financial_model/debugging'
|
|
6
|
+
require_relative 'financial_model/date_helpers'
|
|
7
|
+
require_relative 'financial_model/validation'
|
|
8
|
+
require_relative 'financial_model/currency_helpers'
|
|
9
|
+
require_relative 'financial_model/variable_matching'
|
|
10
|
+
require_relative 'financial_model/account_balances'
|
|
11
|
+
require_relative 'financial_model/period_flows'
|
|
12
|
+
require_relative 'financial_model/category_values'
|
|
13
|
+
require_relative 'financial_model/account_hierarchy'
|
|
14
|
+
|
|
15
|
+
module FinIt
|
|
16
|
+
# Custom error for balance sheet validation
|
|
17
|
+
class BalanceSheetValidationError < StandardError; end
|
|
18
|
+
|
|
19
|
+
# Custom error for start date validation
|
|
20
|
+
class StartDateValidationError < StandardError; end
|
|
21
|
+
|
|
22
|
+
# Custom error for undefined variables in formulas
|
|
23
|
+
class UndefinedVariableError < StandardError; end
|
|
24
|
+
|
|
25
|
+
# The built financial model - coordinates calculator, accounts, and transactions
|
|
26
|
+
class FinancialModel
|
|
27
|
+
include FinancialModel::Debugging
|
|
28
|
+
include FinancialModel::DateHelpers
|
|
29
|
+
include FinancialModel::Validation
|
|
30
|
+
include FinancialModel::CurrencyHelpers
|
|
31
|
+
include FinancialModel::VariableMatching
|
|
32
|
+
include FinancialModel::AccountBalances
|
|
33
|
+
include FinancialModel::PeriodFlows
|
|
34
|
+
include FinancialModel::CategoryValues
|
|
35
|
+
include FinancialModel::AccountHierarchy
|
|
36
|
+
|
|
37
|
+
attr_reader :calculator, :categories, :config, :accounts, :complex_models, :category_accounts
|
|
38
|
+
|
|
39
|
+
def initialize(calculator, categories, config, accounts = {}, category_accounts = {})
|
|
40
|
+
@calculator = calculator
|
|
41
|
+
@categories = categories
|
|
42
|
+
@config = config
|
|
43
|
+
@accounts = accounts
|
|
44
|
+
@category_accounts = category_accounts # Map category -> account for bidirectional relationship
|
|
45
|
+
@start_date = config[:start_date]
|
|
46
|
+
@complex_models = config[:complex_models] || {}
|
|
47
|
+
@transaction_generator = TransactionGenerator.new(self)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Calculate all values for a specific date
|
|
51
|
+
def calculate_at(date)
|
|
52
|
+
result = {}
|
|
53
|
+
|
|
54
|
+
@calculator.variable_names.each do |name|
|
|
55
|
+
result[name] = @calculator.calculate(name, date: date)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Calculate for a date range
|
|
62
|
+
def calculate_range(start_date, end_date, frequency: :monthly)
|
|
63
|
+
@calculator.calculate_range(start_date, end_date, frequency: frequency)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get a specific value
|
|
67
|
+
def get(name, date: nil)
|
|
68
|
+
@calculator.get(name, date: date)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Generate a report
|
|
72
|
+
def to_report(date: nil)
|
|
73
|
+
calculate_at(date || Date.today)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Export to hash
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
config: @config,
|
|
80
|
+
categories: @categories,
|
|
81
|
+
values: to_report
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get start date
|
|
86
|
+
def start_date
|
|
87
|
+
@start_date
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Generate all transactions up to end_date
|
|
91
|
+
def generate_transactions(end_date)
|
|
92
|
+
@transaction_generator.generate_transactions(end_date)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Query transactions
|
|
96
|
+
def transactions(date_range: nil, account: nil, variable: nil)
|
|
97
|
+
@transaction_generator.transactions(
|
|
98
|
+
date_range: date_range,
|
|
99
|
+
account: account,
|
|
100
|
+
variable: variable
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get variable value for a specific period
|
|
105
|
+
def variable_value(variable_name, date:, period_type: :annual)
|
|
106
|
+
@calculator.calculate(
|
|
107
|
+
variable_name,
|
|
108
|
+
date: date,
|
|
109
|
+
output_currency: @config[:default_currency],
|
|
110
|
+
period_type: period_type
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if transaction cache is valid
|
|
115
|
+
def transaction_cache_valid?
|
|
116
|
+
@transaction_generator.cache_valid?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Calculate period net income (Income - Expenses) for a date range
|
|
120
|
+
def period_net_income(start_date, end_date, output_currency: nil, filters: {})
|
|
121
|
+
output_currency ||= @config[:default_currency]
|
|
122
|
+
start_date = parse_date(start_date)
|
|
123
|
+
end_date = parse_date(end_date)
|
|
124
|
+
|
|
125
|
+
# Get all income categories
|
|
126
|
+
income_categories = @categories.select { |c| c.type == :income }
|
|
127
|
+
|
|
128
|
+
# Get all expense categories
|
|
129
|
+
expense_categories = @categories.select { |c| c.type == :expense }
|
|
130
|
+
|
|
131
|
+
# Calculate total income for the period using account hierarchy
|
|
132
|
+
total_income = income_categories.sum do |category|
|
|
133
|
+
category_total_via_account(
|
|
134
|
+
category,
|
|
135
|
+
start_date,
|
|
136
|
+
end_date,
|
|
137
|
+
period_type: :annual,
|
|
138
|
+
output_currency: output_currency,
|
|
139
|
+
filters: filters,
|
|
140
|
+
use_balance: false
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Calculate total expenses for the period using account hierarchy
|
|
145
|
+
# Note: account flows for expenses are negative (debits), so we need to take absolute value
|
|
146
|
+
total_expenses = expense_categories.sum do |category|
|
|
147
|
+
expense_value = category_total_via_account(
|
|
148
|
+
category,
|
|
149
|
+
start_date,
|
|
150
|
+
end_date,
|
|
151
|
+
period_type: :annual,
|
|
152
|
+
output_currency: output_currency,
|
|
153
|
+
filters: filters,
|
|
154
|
+
use_balance: false
|
|
155
|
+
)
|
|
156
|
+
# Expense account flows are negative, so take absolute value for calculation
|
|
157
|
+
expense_value.abs
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Net income = Income - Expenses
|
|
161
|
+
net_income = total_income - total_expenses
|
|
162
|
+
|
|
163
|
+
# Convert to float if Money object
|
|
164
|
+
net_income.is_a?(Money) ? net_income.to_f : net_income.to_f
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ========== Plan/Scenario Methods ==========
|
|
168
|
+
|
|
169
|
+
# Get all defined plans
|
|
170
|
+
def plans
|
|
171
|
+
@config[:plans] || {}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Apply a single plan and return a new isolated model
|
|
175
|
+
# @param plan_or_name [Plan, Symbol] The plan object or name of a registered plan
|
|
176
|
+
# @return [FinancialModel] A new model with the plan applied
|
|
177
|
+
def with_plan(plan_or_name)
|
|
178
|
+
plan = resolve_plan(plan_or_name)
|
|
179
|
+
raise ArgumentError, "Plan not found: #{plan_or_name}" unless plan
|
|
180
|
+
|
|
181
|
+
cloned = clone_model
|
|
182
|
+
cloned.apply_plan!(plan)
|
|
183
|
+
cloned
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Apply multiple plans in order and return a new isolated model
|
|
187
|
+
# Later plans override earlier ones on conflicts
|
|
188
|
+
# @param plan_names [Array<Symbol, Plan>] The plans to apply
|
|
189
|
+
# @return [FinancialModel] A new model with all plans applied
|
|
190
|
+
def with_plans(*plan_names)
|
|
191
|
+
cloned = clone_model
|
|
192
|
+
plan_names.flatten.each do |plan_or_name|
|
|
193
|
+
plan = resolve_plan(plan_or_name)
|
|
194
|
+
raise ArgumentError, "Plan not found: #{plan_or_name}" unless plan
|
|
195
|
+
cloned.apply_plan!(plan)
|
|
196
|
+
end
|
|
197
|
+
cloned
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Apply a plan to this model (mutates this instance)
|
|
201
|
+
# @param plan [Plan] The plan to apply
|
|
202
|
+
# @return [self]
|
|
203
|
+
def apply_plan!(plan)
|
|
204
|
+
plan.overrides.each do |override|
|
|
205
|
+
apply_override(override, plan)
|
|
206
|
+
end
|
|
207
|
+
@transaction_generator.invalidate_cache! if @transaction_generator.respond_to?(:invalidate_cache!)
|
|
208
|
+
self
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Create a deep clone of this model for isolation
|
|
212
|
+
# @return [FinancialModel] A new independent model
|
|
213
|
+
def clone_model
|
|
214
|
+
cloned_calculator = @calculator.deep_clone
|
|
215
|
+
cloned_categories = deep_clone_categories(@categories)
|
|
216
|
+
cloned_accounts = deep_clone_accounts(@accounts)
|
|
217
|
+
cloned_category_accounts = rebuild_category_accounts(cloned_categories, cloned_accounts)
|
|
218
|
+
|
|
219
|
+
cloned_config = @config.dup
|
|
220
|
+
cloned_config[:plans] = @config[:plans]&.transform_values(&:dup)
|
|
221
|
+
cloned_config[:complex_models] = @config[:complex_models]&.transform_values(&:dup)
|
|
222
|
+
|
|
223
|
+
FinancialModel.new(
|
|
224
|
+
cloned_calculator,
|
|
225
|
+
cloned_categories,
|
|
226
|
+
cloned_config,
|
|
227
|
+
cloned_accounts,
|
|
228
|
+
cloned_category_accounts
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def resolve_plan(plan_or_name)
|
|
235
|
+
case plan_or_name
|
|
236
|
+
when Plan
|
|
237
|
+
plan_or_name
|
|
238
|
+
when Symbol, String
|
|
239
|
+
plans[plan_or_name.to_sym]
|
|
240
|
+
else
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def apply_override(override, plan)
|
|
246
|
+
var_name = override[:variable]
|
|
247
|
+
start_date = override[:start_date] || plan.start_date
|
|
248
|
+
end_date = override[:end_date] || plan.end_date
|
|
249
|
+
|
|
250
|
+
case override[:type]
|
|
251
|
+
when :set
|
|
252
|
+
@calculator.set_override(var_name, override[:value],
|
|
253
|
+
start_date: start_date, end_date: end_date)
|
|
254
|
+
when :scale
|
|
255
|
+
@calculator.scale_variable(var_name, override[:factor],
|
|
256
|
+
start_date: start_date, end_date: end_date)
|
|
257
|
+
when :adjust
|
|
258
|
+
@calculator.adjust_variable(var_name, override[:amount],
|
|
259
|
+
start_date: start_date, end_date: end_date)
|
|
260
|
+
when :formula
|
|
261
|
+
@calculator.override_formula(var_name, override[:formula],
|
|
262
|
+
start_date: start_date, end_date: end_date)
|
|
263
|
+
when :opening_balance
|
|
264
|
+
account = @accounts[override[:account]]
|
|
265
|
+
raise ArgumentError, "Account not found: #{override[:account]}" unless account
|
|
266
|
+
account.instance_variable_set(:@opening_balance, override[:amount])
|
|
267
|
+
when :add_variable
|
|
268
|
+
add_variable_from_override(override, plan)
|
|
269
|
+
when :add_calculated
|
|
270
|
+
add_calculated_from_override(override, plan)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def add_variable_from_override(override, plan)
|
|
275
|
+
# Find category
|
|
276
|
+
category = find_category_by_name(override[:category])
|
|
277
|
+
return unless category
|
|
278
|
+
|
|
279
|
+
# Create a simple variable builder context
|
|
280
|
+
var_builder = VariableBuilderContext.new
|
|
281
|
+
override[:block]&.call(var_builder)
|
|
282
|
+
|
|
283
|
+
# Add to calculator
|
|
284
|
+
@calculator.define_variable(
|
|
285
|
+
override[:name],
|
|
286
|
+
var_builder.value_amount,
|
|
287
|
+
currency: override[:currency] || @config[:default_currency],
|
|
288
|
+
frequency: var_builder.frequency || :monthly,
|
|
289
|
+
start_date: var_builder.start_date || plan.start_date,
|
|
290
|
+
end_date: var_builder.end_date || plan.end_date
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Add to category's variables
|
|
294
|
+
category.variables << {
|
|
295
|
+
name: override[:name],
|
|
296
|
+
type: :financial,
|
|
297
|
+
currency: override[:currency] || @config[:default_currency],
|
|
298
|
+
frequency: var_builder.frequency || :monthly
|
|
299
|
+
}
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def add_calculated_from_override(override, plan)
|
|
303
|
+
category = find_category_by_name(override[:category])
|
|
304
|
+
return unless category
|
|
305
|
+
|
|
306
|
+
options = override[:options] || {}
|
|
307
|
+
@calculator.define_calculated(
|
|
308
|
+
override[:name],
|
|
309
|
+
override[:formula],
|
|
310
|
+
start_date: options[:start_date] || plan.start_date,
|
|
311
|
+
end_date: options[:end_date] || plan.end_date
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
category.variables << {
|
|
315
|
+
name: override[:name],
|
|
316
|
+
type: :calculated,
|
|
317
|
+
formula: override[:formula]
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def find_category_by_name(name)
|
|
322
|
+
name = name.to_sym
|
|
323
|
+
@categories.each do |cat|
|
|
324
|
+
return cat if cat.name == name
|
|
325
|
+
found = cat.find_subcategory(name)
|
|
326
|
+
return found if found
|
|
327
|
+
end
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def deep_clone_categories(categories)
|
|
332
|
+
categories.map { |cat| cat.deep_clone }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def deep_clone_accounts(accounts)
|
|
336
|
+
cloned = {}
|
|
337
|
+
accounts.each do |name, account|
|
|
338
|
+
cloned[name] = account.deep_clone
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Rebuild parent-child relationships
|
|
342
|
+
accounts.each do |name, original_account|
|
|
343
|
+
if original_account.parent
|
|
344
|
+
cloned[name].instance_variable_set(:@parent, cloned[original_account.parent.name])
|
|
345
|
+
cloned[original_account.parent.name].children << cloned[name]
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
cloned
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def rebuild_category_accounts(cloned_categories, cloned_accounts)
|
|
353
|
+
result = {}
|
|
354
|
+
@category_accounts.each do |original_cat, original_account|
|
|
355
|
+
# Find corresponding cloned category
|
|
356
|
+
cloned_cat = find_cloned_category(cloned_categories, original_cat.name)
|
|
357
|
+
if cloned_cat && cloned_accounts[original_account.name]
|
|
358
|
+
result[cloned_cat] = cloned_accounts[original_account.name]
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
result
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def find_cloned_category(categories, name)
|
|
365
|
+
categories.each do |cat|
|
|
366
|
+
return cat if cat.name == name
|
|
367
|
+
found = cat.find_subcategory(name)
|
|
368
|
+
return found if found
|
|
369
|
+
end
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Simple context for building variables from plan overrides
|
|
375
|
+
class VariableBuilderContext
|
|
376
|
+
attr_reader :value_amount, :frequency, :start_date, :end_date
|
|
377
|
+
|
|
378
|
+
def initialize
|
|
379
|
+
@value_amount = 0
|
|
380
|
+
@frequency = :monthly
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def value(amount, start_date: nil, end_date: nil)
|
|
384
|
+
@value_amount = amount
|
|
385
|
+
@start_date = start_date
|
|
386
|
+
@end_date = end_date
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def frequency=(freq)
|
|
390
|
+
@frequency = freq
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
# Template for reusable complex models (e.g., mortgage payment template)
|
|
5
|
+
class ModelTemplate
|
|
6
|
+
attr_reader :name, :variables, :default_debit_accounts, :default_credit_accounts, :calculation_block
|
|
7
|
+
|
|
8
|
+
def initialize(name)
|
|
9
|
+
@name = name
|
|
10
|
+
@variables = []
|
|
11
|
+
@default_debit_accounts = []
|
|
12
|
+
@default_credit_accounts = []
|
|
13
|
+
@calculation_block = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_variable(var_name)
|
|
17
|
+
@variables << var_name.to_sym
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def set_default_debit_accounts(accounts)
|
|
21
|
+
@default_debit_accounts = Array(accounts).map(&:to_sym)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def set_default_credit_accounts(accounts)
|
|
25
|
+
@default_credit_accounts = Array(accounts).map(&:to_sym)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def set_calculation(&block)
|
|
29
|
+
@calculation_block = block
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Instantiate this template as a ComplexModel
|
|
33
|
+
def instantiate(instance_name, params = {}, accounts: nil, default_currency: 'USD', &block)
|
|
34
|
+
# Extract reserved params
|
|
35
|
+
start_date = params.delete(:start_date) || params.delete('start_date')
|
|
36
|
+
end_date = params.delete(:end_date) || params.delete('end_date')
|
|
37
|
+
frequency = params.delete(:frequency) || params.delete('frequency') || :monthly
|
|
38
|
+
debit_account = params.delete(:debit_account) || params.delete('debit_account') || @default_debit_accounts.first
|
|
39
|
+
credit_account = params.delete(:credit_account) || params.delete('credit_account') || @default_credit_accounts.first
|
|
40
|
+
project = params.delete(:project) || params.delete('project')
|
|
41
|
+
|
|
42
|
+
# Auto-create debit_account if it doesn't exist
|
|
43
|
+
if debit_account && accounts
|
|
44
|
+
debit_account = ensure_account_exists(debit_account, accounts, default_currency: default_currency)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Remaining params become input variables
|
|
48
|
+
input_variables = params
|
|
49
|
+
|
|
50
|
+
# If debit_account is a template variable, add it to input_variables with the resolved account name
|
|
51
|
+
if @variables.include?(:debit_account) && debit_account
|
|
52
|
+
input_variables[:debit_account] = debit_account
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# If credit_account is a template variable, add it to input_variables
|
|
56
|
+
if @variables.include?(:credit_account) && credit_account
|
|
57
|
+
input_variables[:credit_account] = credit_account
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Validate required variables are provided
|
|
61
|
+
missing_vars = @variables - input_variables.keys.map(&:to_sym)
|
|
62
|
+
if missing_vars.any?
|
|
63
|
+
raise ArgumentError, "Missing required variables for #{@name}: #{missing_vars.join(', ')}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Create ComplexModel with wrapped calculation block
|
|
67
|
+
require_relative 'complex_model'
|
|
68
|
+
ComplexModel.new(
|
|
69
|
+
instance_name,
|
|
70
|
+
start_date: start_date,
|
|
71
|
+
end_date: end_date,
|
|
72
|
+
frequency: frequency,
|
|
73
|
+
debit_account: debit_account,
|
|
74
|
+
credit_account: credit_account,
|
|
75
|
+
project: project
|
|
76
|
+
) do |date, context|
|
|
77
|
+
# Merge template input variables into context
|
|
78
|
+
full_context = input_variables.merge(context)
|
|
79
|
+
# Call template's calculation block
|
|
80
|
+
@calculation_block.call(date, full_context)
|
|
81
|
+
end.tap do |model|
|
|
82
|
+
# Set input variables
|
|
83
|
+
input_variables.each do |key, value|
|
|
84
|
+
model.input_variable(key, value)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Ensure account exists, creating it if necessary
|
|
92
|
+
# Supports array notation for nested accounts (e.g., [:expense, :loan_interest, :vendor_loan])
|
|
93
|
+
# but only creates the final account for now
|
|
94
|
+
def ensure_account_exists(account_name, accounts_hash, default_currency: 'USD')
|
|
95
|
+
# Handle array notation - extract the final account name
|
|
96
|
+
actual_account_name = if account_name.is_a?(Array)
|
|
97
|
+
account_name.last
|
|
98
|
+
else
|
|
99
|
+
account_name
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Convert to symbol if needed
|
|
103
|
+
actual_account_name = actual_account_name.to_sym
|
|
104
|
+
|
|
105
|
+
# Check if account already exists
|
|
106
|
+
return actual_account_name if accounts_hash.key?(actual_account_name)
|
|
107
|
+
|
|
108
|
+
# Create the account as equity type (default for expense accounts)
|
|
109
|
+
require_relative 'account'
|
|
110
|
+
new_account = Account.new(
|
|
111
|
+
actual_account_name,
|
|
112
|
+
type: :equity,
|
|
113
|
+
currency: default_currency,
|
|
114
|
+
opening_balance: 0
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
accounts_hash[actual_account_name] = new_account
|
|
118
|
+
actual_account_name
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module Outputs
|
|
5
|
+
class BaseOutput
|
|
6
|
+
attr_reader :report, :options
|
|
7
|
+
|
|
8
|
+
def initialize(report, options = {})
|
|
9
|
+
@report = report
|
|
10
|
+
@options = options
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def generate
|
|
14
|
+
raise NotImplementedError, "Subclasses must implement generate"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
protected
|
|
18
|
+
|
|
19
|
+
def format_number(num)
|
|
20
|
+
return "N/A" if num.nil?
|
|
21
|
+
|
|
22
|
+
if num.is_a?(Money)
|
|
23
|
+
num.format(symbol: false, thousands_separator: ',')
|
|
24
|
+
else
|
|
25
|
+
num.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def currency_symbol(currency)
|
|
30
|
+
case currency.to_s
|
|
31
|
+
when 'USD' then '$'
|
|
32
|
+
when 'EUR' then '€'
|
|
33
|
+
when 'MXN' then 'MXN'
|
|
34
|
+
when 'GBP' then '£'
|
|
35
|
+
when 'JPY' then '¥'
|
|
36
|
+
else currency.to_s + ' '
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def format_currency(value, currency = nil)
|
|
41
|
+
currency ||= @report.output_currency
|
|
42
|
+
|
|
43
|
+
if value.is_a?(Money)
|
|
44
|
+
"#{currency_symbol(value.currency.iso_code)}#{format_number(value)}"
|
|
45
|
+
else
|
|
46
|
+
"#{currency_symbol(currency)}#{format_number(value)}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|