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,938 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'variable_builder'
|
|
4
|
+
require_relative 'calculated_builder'
|
|
5
|
+
require_relative 'account_builder'
|
|
6
|
+
require_relative 'config_builder'
|
|
7
|
+
require_relative 'project_inheritance_resolver'
|
|
8
|
+
require_relative 'plan_builder'
|
|
9
|
+
require_relative '../account'
|
|
10
|
+
require_relative '../categories/category'
|
|
11
|
+
|
|
12
|
+
module FinIt
|
|
13
|
+
module DSL
|
|
14
|
+
# DSL for defining financial models
|
|
15
|
+
class ModelBuilder
|
|
16
|
+
attr_reader :calculator, :categories, :accounts
|
|
17
|
+
|
|
18
|
+
def initialize(default_currency: 'USD')
|
|
19
|
+
@calculator = Calculator.new(default_currency: default_currency)
|
|
20
|
+
@categories = []
|
|
21
|
+
@accounts = {}
|
|
22
|
+
@category_accounts = {} # Map category -> account for bidirectional relationship
|
|
23
|
+
@current_category = nil
|
|
24
|
+
@config = { default_currency: default_currency }
|
|
25
|
+
@config[:model_templates] = {}
|
|
26
|
+
@config[:complex_models] = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Configuration block
|
|
30
|
+
def config(&block)
|
|
31
|
+
config_builder = ConfigBuilder.new(@config)
|
|
32
|
+
config_builder.instance_eval(&block)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Define an account
|
|
36
|
+
def account(name, &block)
|
|
37
|
+
account_builder = AccountBuilder.new(name)
|
|
38
|
+
account_builder.instance_eval(&block) if block_given?
|
|
39
|
+
|
|
40
|
+
# If inside a category block, inherit type from category if not explicitly set
|
|
41
|
+
inherited_type = nil
|
|
42
|
+
if @current_category
|
|
43
|
+
category_type = @current_category.type
|
|
44
|
+
if [:asset, :liability, :equity].include?(category_type)
|
|
45
|
+
inherited_type = category_type
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Use explicit type if provided, otherwise use inherited type
|
|
50
|
+
account_type = account_builder.account_type || inherited_type
|
|
51
|
+
|
|
52
|
+
# Type is required - raise error if neither explicit nor inherited
|
|
53
|
+
unless account_type
|
|
54
|
+
raise ArgumentError, "Account '#{name}' must specify type: or be defined within an asset/liability/equity category"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
account_obj = Account.new(
|
|
58
|
+
name,
|
|
59
|
+
type: account_type,
|
|
60
|
+
currency: account_builder.account_currency || @config[:default_currency],
|
|
61
|
+
opening_balance: account_builder.account_opening_balance || 0,
|
|
62
|
+
opening_balance_credit_account: account_builder.account_opening_balance_credit_account || :equity
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@accounts[name] = account_obj
|
|
66
|
+
|
|
67
|
+
# If inside a category block, automatically create a variable that references this account
|
|
68
|
+
if @current_category && [:asset, :liability, :equity].include?(@current_category.type)
|
|
69
|
+
# Create variable with account name directly (no "_balance" suffix)
|
|
70
|
+
var_data = {
|
|
71
|
+
name: name,
|
|
72
|
+
type: :financial,
|
|
73
|
+
frequency: :annual,
|
|
74
|
+
currency: account_obj.currency,
|
|
75
|
+
account: name,
|
|
76
|
+
description: account_obj.name.to_s.humanize
|
|
77
|
+
}
|
|
78
|
+
@current_category.variables << var_data
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Define a scenario plan
|
|
83
|
+
# @param name [Symbol] The plan name
|
|
84
|
+
# @param description [String] Optional description
|
|
85
|
+
# @param start_date [Date, String, nil] When plan applies
|
|
86
|
+
# @param end_date [Date, String, nil] When plan ends
|
|
87
|
+
def plan(name, description: nil, start_date: nil, end_date: nil, &block)
|
|
88
|
+
plan_builder = PlanBuilder.new(name, description: description,
|
|
89
|
+
start_date: start_date, end_date: end_date)
|
|
90
|
+
plan_builder.instance_eval(&block) if block_given?
|
|
91
|
+
|
|
92
|
+
@config[:plans] ||= {}
|
|
93
|
+
@config[:plans][name.to_sym] = plan_builder.build
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Define a category with type (can be nested)
|
|
97
|
+
# Type is optional for nested categories - inherits from parent
|
|
98
|
+
# default_account: account used by children unless overridden
|
|
99
|
+
# defaults: { frequency:, start_date:, end_date: } inherited by children
|
|
100
|
+
def category(name, type: nil, description: nil, comments: nil, default_account: nil, defaults: nil, &block)
|
|
101
|
+
# Determine parent for inheritance
|
|
102
|
+
parent_category = @current_category
|
|
103
|
+
|
|
104
|
+
# Create category - type inheritance happens in Category constructor
|
|
105
|
+
category_obj = Categories::Category.new(
|
|
106
|
+
name,
|
|
107
|
+
type: type,
|
|
108
|
+
parent: parent_category,
|
|
109
|
+
description: description,
|
|
110
|
+
default_account: default_account,
|
|
111
|
+
defaults: defaults
|
|
112
|
+
)
|
|
113
|
+
category_obj.metadata[:comments] = comments if comments
|
|
114
|
+
|
|
115
|
+
# Add to parent's children or top-level categories
|
|
116
|
+
if parent_category
|
|
117
|
+
parent_category.children << category_obj
|
|
118
|
+
else
|
|
119
|
+
@categories << category_obj
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Create account for category (except non-financial categories: driver, metric, assumption)
|
|
123
|
+
non_financial_types = [:driver, :metric, :assumption]
|
|
124
|
+
unless non_financial_types.include?(category_obj.type)
|
|
125
|
+
parent_account = nil
|
|
126
|
+
if @current_category && !non_financial_types.include?(@current_category.type)
|
|
127
|
+
parent_account = get_or_create_category_account(@current_category)
|
|
128
|
+
end
|
|
129
|
+
category_account = get_or_create_category_account(category_obj, parent_account: parent_account)
|
|
130
|
+
@category_accounts[category_obj] = category_account
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
old_category = @current_category
|
|
134
|
+
@current_category = category_obj
|
|
135
|
+
|
|
136
|
+
instance_eval(&block) if block_given?
|
|
137
|
+
|
|
138
|
+
@current_category = old_category
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Define driver variables (non-financial assumptions)
|
|
142
|
+
# This is a convenience method that creates a driver category and allows defining variables within it
|
|
143
|
+
def driver(description: nil, &block)
|
|
144
|
+
# Check if driver category already exists
|
|
145
|
+
driver_category = @categories.find { |c| c.name == :drivers && c.parent.nil? }
|
|
146
|
+
|
|
147
|
+
unless driver_category
|
|
148
|
+
driver_category = Categories::Category.new(:drivers, type: :driver, description: description || "Business drivers")
|
|
149
|
+
@categories << driver_category
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
old_category = @current_category
|
|
153
|
+
@current_category = driver_category
|
|
154
|
+
|
|
155
|
+
instance_eval(&block) if block_given?
|
|
156
|
+
|
|
157
|
+
@current_category = old_category
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Define a variable with currency support
|
|
161
|
+
def variable(name, type: :financial, frequency: nil, currency: nil, description: nil, unit: nil, account: nil, project: nil, &block)
|
|
162
|
+
# Driver variables can have no currency, financial variables get default currency
|
|
163
|
+
is_driver = @current_category && @current_category.type == :driver
|
|
164
|
+
currency = nil if is_driver
|
|
165
|
+
currency ||= @config[:default_currency] unless is_driver
|
|
166
|
+
|
|
167
|
+
# Apply defaults from category
|
|
168
|
+
category_defaults = @current_category&.defaults || {}
|
|
169
|
+
frequency ||= category_defaults[:frequency] || :annual
|
|
170
|
+
account ||= @current_category&.default_account
|
|
171
|
+
|
|
172
|
+
# Skip account creation for driver variables
|
|
173
|
+
pl_account = nil # P&L account for income/expense tracking
|
|
174
|
+
unless is_driver
|
|
175
|
+
user_specified_account = account # Preserve user's account reference
|
|
176
|
+
|
|
177
|
+
# Auto-create account for variable as sub-account of category's account
|
|
178
|
+
if @current_category && [:income, :expense, :asset, :liability, :equity].include?(@current_category.type)
|
|
179
|
+
category_account = get_or_create_category_account(@current_category)
|
|
180
|
+
variable_account = create_variable_account(name, @current_category.type, category_account, currency)
|
|
181
|
+
|
|
182
|
+
# For income/expense:
|
|
183
|
+
# - account = user's asset account (where money flows to/from, e.g. :checking)
|
|
184
|
+
# If not specified, leave nil so transaction generator uses default asset account
|
|
185
|
+
# - pl_account = auto-created P&L account (for income statement, e.g. :salary)
|
|
186
|
+
# For asset/liability/equity:
|
|
187
|
+
# - account = user's account if specified (e.g. :checking), otherwise the variable account
|
|
188
|
+
# This allows users to define variables that reference existing accounts for balance sheet reporting
|
|
189
|
+
if [:income, :expense].include?(@current_category.type)
|
|
190
|
+
pl_account = variable_account.name # Store P&L account for transaction generation
|
|
191
|
+
# Keep user's account or nil - don't fall back to P&L account
|
|
192
|
+
# Transaction generator will use default asset if nil
|
|
193
|
+
account = user_specified_account
|
|
194
|
+
else
|
|
195
|
+
# For balance sheet categories, preserve user's account reference if specified
|
|
196
|
+
account = user_specified_account || variable_account.name
|
|
197
|
+
end
|
|
198
|
+
elsif account && !@accounts.key?(account.is_a?(Array) ? account.last : account)
|
|
199
|
+
# Legacy: auto-create account if specified but doesn't exist
|
|
200
|
+
if @current_category && [:income, :expense].include?(@current_category.type)
|
|
201
|
+
account = ensure_account_exists(account, @current_category.type)
|
|
202
|
+
else
|
|
203
|
+
raise AccountNotFoundError.new(account)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
var_builder = VariableBuilder.new(name, @calculator, currency, frequency: frequency, account: account, project: project)
|
|
209
|
+
var_builder.instance_eval(&block) if block_given?
|
|
210
|
+
|
|
211
|
+
var_data = {
|
|
212
|
+
name: name,
|
|
213
|
+
type: type,
|
|
214
|
+
frequency: frequency,
|
|
215
|
+
currency: currency,
|
|
216
|
+
description: description,
|
|
217
|
+
unit: unit,
|
|
218
|
+
account: account,
|
|
219
|
+
pl_account: pl_account, # P&L account for income/expense tracking
|
|
220
|
+
project: project
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@current_category.variables << var_data if @current_category
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Define a calculated variable with formula
|
|
227
|
+
def calculated(name, formula: nil, description: nil, round_to: nil, start_date: nil, end_date: nil, frequency: nil, payment_schedule: nil, debit_account: nil, credit_account: nil, account: nil, &block)
|
|
228
|
+
# Apply defaults from category
|
|
229
|
+
category_defaults = @current_category&.defaults || {}
|
|
230
|
+
account ||= @current_category&.default_account
|
|
231
|
+
frequency ||= category_defaults[:frequency]
|
|
232
|
+
start_date ||= category_defaults[:start_date]
|
|
233
|
+
end_date ||= category_defaults[:end_date]
|
|
234
|
+
|
|
235
|
+
final_formula = nil
|
|
236
|
+
final_frequency = frequency
|
|
237
|
+
final_payment_schedule = payment_schedule
|
|
238
|
+
final_start_date = start_date
|
|
239
|
+
final_end_date = end_date
|
|
240
|
+
final_round_to = round_to
|
|
241
|
+
|
|
242
|
+
# Auto-create account for calculated variable as sub-account of category's account
|
|
243
|
+
# Skip for driver categories
|
|
244
|
+
calculated_account = nil
|
|
245
|
+
if @current_category && @current_category.type != :driver && [:income, :expense, :asset, :liability, :equity].include?(@current_category.type)
|
|
246
|
+
category_account = get_or_create_category_account(@current_category)
|
|
247
|
+
calculated_account = create_variable_account(name, @current_category.type, category_account, @config[:default_currency])
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Handle convenience 'account:' parameter - sets debit/credit based on category type
|
|
251
|
+
# For asset/liability/equity categories, store account directly for balance sheet reporting
|
|
252
|
+
# Auto-create accounts if they don't exist (for income/expense categories)
|
|
253
|
+
final_account = nil
|
|
254
|
+
if account && !debit_account && !credit_account && @current_category
|
|
255
|
+
if @current_category.type == :income
|
|
256
|
+
# For income: Debit asset (where money goes), Credit income P&L account
|
|
257
|
+
# - final_debit_account = asset account (user-specified via 'account' param)
|
|
258
|
+
# - final_credit_account = P&L income account (auto-created)
|
|
259
|
+
final_debit_account = account
|
|
260
|
+
final_credit_account = calculated_account ? calculated_account.name : nil
|
|
261
|
+
# Auto-create P&L account if it doesn't exist
|
|
262
|
+
if final_credit_account && !@accounts.key?(final_credit_account.is_a?(Array) ? final_credit_account.last : final_credit_account) && [:income, :expense].include?(@current_category.type)
|
|
263
|
+
final_credit_account = ensure_account_exists(final_credit_account, @current_category.type)
|
|
264
|
+
end
|
|
265
|
+
elsif @current_category.type == :expense
|
|
266
|
+
# For expenses: Debit expense P&L account, Credit asset/liability (where money comes from)
|
|
267
|
+
# - final_debit_account = P&L expense account (auto-created)
|
|
268
|
+
# - final_credit_account = asset/liability account (user-specified via 'account' param)
|
|
269
|
+
final_debit_account = calculated_account ? calculated_account.name : nil
|
|
270
|
+
if final_debit_account && !@accounts.key?(final_debit_account.is_a?(Array) ? final_debit_account.last : final_debit_account) && [:income, :expense].include?(@current_category.type)
|
|
271
|
+
final_debit_account = ensure_account_exists(final_debit_account, @current_category.type)
|
|
272
|
+
end
|
|
273
|
+
# Use user-specified account as credit (asset/liability where money comes from)
|
|
274
|
+
final_credit_account = account
|
|
275
|
+
else
|
|
276
|
+
# For asset/liability/equity categories, use calculated account if created
|
|
277
|
+
final_account = calculated_account ? calculated_account.name : account
|
|
278
|
+
final_debit_account = debit_account
|
|
279
|
+
final_credit_account = credit_account
|
|
280
|
+
end
|
|
281
|
+
else
|
|
282
|
+
# Handle explicit debit_account/credit_account parameters
|
|
283
|
+
# For income: credit_account is asset account, variable's account is income account
|
|
284
|
+
# For expense: debit_account is asset account, variable's account is expense account
|
|
285
|
+
if @current_category
|
|
286
|
+
if @current_category.type == :income
|
|
287
|
+
# Income: Debit asset (credit_account param), Credit income (variable's account)
|
|
288
|
+
if credit_account
|
|
289
|
+
final_debit_account = credit_account # Asset account where money goes
|
|
290
|
+
final_credit_account = calculated_account ? calculated_account.name : find_equity_account # Income account
|
|
291
|
+
elsif debit_account
|
|
292
|
+
final_debit_account = debit_account
|
|
293
|
+
final_credit_account = calculated_account ? calculated_account.name : find_equity_account
|
|
294
|
+
elsif calculated_account
|
|
295
|
+
final_debit_account = find_default_asset_account
|
|
296
|
+
final_credit_account = calculated_account.name
|
|
297
|
+
end
|
|
298
|
+
elsif @current_category.type == :expense
|
|
299
|
+
# Expense: Debit expense (variable's account), Credit asset (debit_account param)
|
|
300
|
+
if debit_account
|
|
301
|
+
final_debit_account = calculated_account ? calculated_account.name : find_equity_account # Expense account
|
|
302
|
+
final_credit_account = debit_account # Asset account where money comes from
|
|
303
|
+
elsif credit_account
|
|
304
|
+
final_debit_account = calculated_account ? calculated_account.name : find_equity_account
|
|
305
|
+
final_credit_account = credit_account
|
|
306
|
+
elsif calculated_account
|
|
307
|
+
final_debit_account = calculated_account.name
|
|
308
|
+
final_credit_account = find_default_asset_account
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Auto-create accounts if they don't exist (for asset accounts)
|
|
314
|
+
if final_debit_account && !@accounts.key?(final_debit_account.is_a?(Array) ? final_debit_account.last : final_debit_account)
|
|
315
|
+
if @current_category && [:income, :expense].include?(@current_category.type)
|
|
316
|
+
# Only auto-create if it's an asset account (not income/expense)
|
|
317
|
+
account_obj = @accounts[final_debit_account.is_a?(Array) ? final_debit_account.last : final_debit_account]
|
|
318
|
+
unless account_obj && [:income, :expense].include?(account_obj.type)
|
|
319
|
+
final_debit_account = ensure_account_exists(final_debit_account, @current_category.type)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
if final_credit_account && !@accounts.key?(final_credit_account.is_a?(Array) ? final_credit_account.last : final_credit_account)
|
|
324
|
+
if @current_category && [:income, :expense].include?(@current_category.type)
|
|
325
|
+
# Only auto-create if it's an asset account (not income/expense)
|
|
326
|
+
account_obj = @accounts[final_credit_account.is_a?(Array) ? final_credit_account.last : final_credit_account]
|
|
327
|
+
unless account_obj && [:income, :expense].include?(account_obj.type)
|
|
328
|
+
final_credit_account = ensure_account_exists(final_credit_account, @current_category.type)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
final_account = account if account
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
if formula
|
|
336
|
+
# Inline syntax: calculated(:name, formula: "a + b")
|
|
337
|
+
final_formula = formula
|
|
338
|
+
elsif block_given?
|
|
339
|
+
# Block syntax: calculated(:name) do formula "a + b" end
|
|
340
|
+
calc_builder = CalculatedBuilder.new(name, @calculator)
|
|
341
|
+
calc_builder.instance_eval(&block)
|
|
342
|
+
|
|
343
|
+
# Use block values or parameters, with parameters taking precedence
|
|
344
|
+
final_frequency = frequency || calc_builder.frequency
|
|
345
|
+
final_payment_schedule = payment_schedule || calc_builder.payment_schedule
|
|
346
|
+
final_start_date = start_date || calc_builder.start_date
|
|
347
|
+
final_end_date = end_date || calc_builder.end_date
|
|
348
|
+
final_round_to = round_to || calc_builder.round_to
|
|
349
|
+
final_formula = calc_builder.formula
|
|
350
|
+
|
|
351
|
+
# Handle account from block if not already set from keyword args
|
|
352
|
+
if calc_builder.account && !final_debit_account && !final_credit_account && @current_category
|
|
353
|
+
if @current_category.type == :income
|
|
354
|
+
# Auto-create account if it doesn't exist
|
|
355
|
+
if !@accounts.key?(calc_builder.account.is_a?(Array) ? calc_builder.account.last : calc_builder.account) && [:income, :expense].include?(@current_category.type)
|
|
356
|
+
final_credit_account = ensure_account_exists(calc_builder.account, @current_category.type)
|
|
357
|
+
else
|
|
358
|
+
final_credit_account = calc_builder.account
|
|
359
|
+
end
|
|
360
|
+
elsif @current_category.type == :expense
|
|
361
|
+
# Auto-create account if it doesn't exist
|
|
362
|
+
if !@accounts.key?(calc_builder.account.is_a?(Array) ? calc_builder.account.last : calc_builder.account) && [:income, :expense].include?(@current_category.type)
|
|
363
|
+
final_debit_account = ensure_account_exists(calc_builder.account, @current_category.type)
|
|
364
|
+
else
|
|
365
|
+
final_debit_account = calc_builder.account
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Auto-create accounts from builder if they don't exist
|
|
371
|
+
if calc_builder.debit_account && !final_debit_account
|
|
372
|
+
if !@accounts.key?(calc_builder.debit_account.is_a?(Array) ? calc_builder.debit_account.last : calc_builder.debit_account) && @current_category && [:income, :expense].include?(@current_category.type)
|
|
373
|
+
final_debit_account = ensure_account_exists(calc_builder.debit_account, @current_category.type)
|
|
374
|
+
else
|
|
375
|
+
final_debit_account = calc_builder.debit_account
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
if calc_builder.credit_account && !final_credit_account
|
|
379
|
+
if !@accounts.key?(calc_builder.credit_account.is_a?(Array) ? calc_builder.credit_account.last : calc_builder.credit_account) && @current_category && [:income, :expense].include?(@current_category.type)
|
|
380
|
+
final_credit_account = ensure_account_exists(calc_builder.credit_account, @current_category.type)
|
|
381
|
+
else
|
|
382
|
+
final_credit_account = calc_builder.credit_account
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Auto-inherit project tag from dependencies
|
|
388
|
+
inherited_project = nil
|
|
389
|
+
if final_formula
|
|
390
|
+
dependencies = @calculator.extract_variable_dependencies(final_formula)
|
|
391
|
+
if dependencies.any?
|
|
392
|
+
resolver = ProjectInheritanceResolver.new(@calculator)
|
|
393
|
+
inherited_project = resolver.resolve_project(dependencies)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
if final_formula
|
|
398
|
+
@calculator.define_calculated(
|
|
399
|
+
name,
|
|
400
|
+
final_formula,
|
|
401
|
+
start_date: final_start_date,
|
|
402
|
+
end_date: final_end_date,
|
|
403
|
+
round_to: final_round_to,
|
|
404
|
+
frequency: final_frequency,
|
|
405
|
+
payment_schedule: final_payment_schedule,
|
|
406
|
+
project: inherited_project
|
|
407
|
+
)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
var_data = {
|
|
411
|
+
name: name,
|
|
412
|
+
type: :calculated,
|
|
413
|
+
description: description,
|
|
414
|
+
formula: final_formula,
|
|
415
|
+
start_date: final_start_date,
|
|
416
|
+
end_date: final_end_date,
|
|
417
|
+
frequency: final_frequency,
|
|
418
|
+
payment_schedule: final_payment_schedule,
|
|
419
|
+
debit_account: final_debit_account,
|
|
420
|
+
credit_account: final_credit_account,
|
|
421
|
+
account: final_account,
|
|
422
|
+
project: inherited_project
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@current_category.variables << var_data if @current_category
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Define a top-level calculated value (outside categories, can be non-financial)
|
|
429
|
+
def calculated_value(name, formula:, description: nil, round_to: nil, start_date: nil, end_date: nil, frequency: nil, &block)
|
|
430
|
+
# Top-level calculated values don't require accounts
|
|
431
|
+
@calculator.define_calculated(
|
|
432
|
+
name,
|
|
433
|
+
formula,
|
|
434
|
+
start_date: start_date,
|
|
435
|
+
end_date: end_date,
|
|
436
|
+
round_to: round_to,
|
|
437
|
+
frequency: frequency
|
|
438
|
+
)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Define a reusable model template
|
|
442
|
+
def define_model(template_name, &block)
|
|
443
|
+
require_relative '../model_template'
|
|
444
|
+
require_relative 'model_template_builder'
|
|
445
|
+
|
|
446
|
+
template = ModelTemplate.new(template_name)
|
|
447
|
+
builder = ModelTemplateBuilder.new(template)
|
|
448
|
+
builder.instance_eval(&block)
|
|
449
|
+
|
|
450
|
+
# Store template
|
|
451
|
+
@config[:model_templates] ||= {}
|
|
452
|
+
@config[:model_templates][template_name] = template
|
|
453
|
+
|
|
454
|
+
# Create dynamic method for instantiation
|
|
455
|
+
define_singleton_method(template_name) do |instance_name = nil, &instantiation_block|
|
|
456
|
+
instance_name ||= "#{template_name}_#{@config[:complex_models]&.length.to_i + 1}".to_sym
|
|
457
|
+
|
|
458
|
+
# Parse instantiation block parameters
|
|
459
|
+
params = {}
|
|
460
|
+
if instantiation_block
|
|
461
|
+
# Evaluate block in a context that captures keyword arguments
|
|
462
|
+
param_capture = ParamCapture.new
|
|
463
|
+
param_capture.instance_eval(&instantiation_block)
|
|
464
|
+
params = param_capture.params
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Instantiate template with accounts reference for auto-creation
|
|
468
|
+
model = template.instantiate(
|
|
469
|
+
instance_name,
|
|
470
|
+
params,
|
|
471
|
+
accounts: @accounts,
|
|
472
|
+
default_currency: @config[:default_currency] || 'USD'
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Store complex model instance
|
|
476
|
+
@config[:complex_models] ||= {}
|
|
477
|
+
@config[:complex_models][instance_name] = model
|
|
478
|
+
|
|
479
|
+
model
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
template
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Helper class to capture parameters from instantiation block
|
|
486
|
+
class ParamCapture
|
|
487
|
+
attr_reader :params
|
|
488
|
+
|
|
489
|
+
def initialize
|
|
490
|
+
@params = {}
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def method_missing(name, *args, &block)
|
|
494
|
+
if args.length == 1
|
|
495
|
+
@params[name] = args.first
|
|
496
|
+
elsif args.empty? && block_given?
|
|
497
|
+
@params[name] = block
|
|
498
|
+
else
|
|
499
|
+
@params[name] = args
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def respond_to_missing?(name, include_private = false)
|
|
504
|
+
true
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Build the final model
|
|
509
|
+
def build
|
|
510
|
+
# Set default start_date if not provided (start of current year)
|
|
511
|
+
unless @config[:start_date]
|
|
512
|
+
@config[:start_date] = Date.new(Date.today.year, 1, 1)
|
|
513
|
+
warn "Warning: No start_date specified. Defaulting to start of current year: #{@config[:start_date]}\n#{caller.join("\n")}"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Ensure we have an equity account
|
|
517
|
+
ensure_equity_account
|
|
518
|
+
|
|
519
|
+
# Validate all variables in income/expense categories have required accounts
|
|
520
|
+
validate_category_variables!
|
|
521
|
+
|
|
522
|
+
# Validate and create model
|
|
523
|
+
model = FinancialModel.new(@calculator, @categories, @config, @accounts, @category_accounts)
|
|
524
|
+
|
|
525
|
+
# Validate model
|
|
526
|
+
model.validate!
|
|
527
|
+
|
|
528
|
+
model
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Get model templates (for testing/debugging)
|
|
532
|
+
def model_templates
|
|
533
|
+
@config[:model_templates] || {}
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
private
|
|
537
|
+
|
|
538
|
+
def ensure_equity_account
|
|
539
|
+
# Calculate balance needed for equity to balance the sheet
|
|
540
|
+
# All values are stored as positive: Assets = Liabilities + Equity
|
|
541
|
+
total_assets = @accounts.values.select { |acc| acc.type == :asset }
|
|
542
|
+
.sum { |acc| acc.opening_balance.to_f }
|
|
543
|
+
total_liabilities = @accounts.values.select { |acc| acc.type == :liability }
|
|
544
|
+
.sum { |acc| acc.opening_balance.to_f }
|
|
545
|
+
|
|
546
|
+
# Equity = Assets - Liabilities (to balance the sheet)
|
|
547
|
+
equity_balance = total_assets - total_liabilities
|
|
548
|
+
|
|
549
|
+
# Check if the main equity account exists (not just any equity account)
|
|
550
|
+
# Expense accounts are also equity type, but we need the main :equity account
|
|
551
|
+
has_main_equity = @accounts.key?(:equity)
|
|
552
|
+
|
|
553
|
+
# Also check if there's an equity category (by type, not name)
|
|
554
|
+
has_equity_category = find_category_by_type(:equity)
|
|
555
|
+
|
|
556
|
+
if has_main_equity
|
|
557
|
+
# Equity account exists - update its balance
|
|
558
|
+
existing_equity = @accounts[:equity]
|
|
559
|
+
# Always update balance to ensure it's correct (accounts may have been created with 0 balance)
|
|
560
|
+
existing_equity.instance_variable_set(:@opening_balance, equity_balance)
|
|
561
|
+
equity_account = existing_equity
|
|
562
|
+
else
|
|
563
|
+
# Get or create equity category account if equity category exists
|
|
564
|
+
parent_account = nil
|
|
565
|
+
if has_equity_category
|
|
566
|
+
parent_account = get_or_create_category_account(has_equity_category)
|
|
567
|
+
# If category account is :equity, use it and update balance
|
|
568
|
+
if parent_account.name == :equity
|
|
569
|
+
parent_account.instance_variable_set(:@opening_balance, equity_balance)
|
|
570
|
+
@accounts[:equity] = parent_account
|
|
571
|
+
equity_account = parent_account
|
|
572
|
+
else
|
|
573
|
+
# Create default equity account with calculated balance
|
|
574
|
+
equity_account = Account.new(
|
|
575
|
+
:equity,
|
|
576
|
+
type: :equity,
|
|
577
|
+
currency: @config[:default_currency] || 'USD',
|
|
578
|
+
opening_balance: equity_balance,
|
|
579
|
+
parent: parent_account
|
|
580
|
+
)
|
|
581
|
+
@accounts[:equity] = equity_account
|
|
582
|
+
end
|
|
583
|
+
else
|
|
584
|
+
# Create default equity account with calculated balance
|
|
585
|
+
equity_account = Account.new(
|
|
586
|
+
:equity,
|
|
587
|
+
type: :equity,
|
|
588
|
+
currency: @config[:default_currency] || 'USD',
|
|
589
|
+
opening_balance: equity_balance
|
|
590
|
+
)
|
|
591
|
+
@accounts[:equity] = equity_account
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Map to equity category if it exists
|
|
595
|
+
if has_equity_category
|
|
596
|
+
@category_accounts[has_equity_category] = equity_account unless @category_accounts[has_equity_category]
|
|
597
|
+
var_data = {
|
|
598
|
+
name: :equity,
|
|
599
|
+
type: :financial,
|
|
600
|
+
frequency: :annual,
|
|
601
|
+
currency: equity_account.currency,
|
|
602
|
+
account: :equity,
|
|
603
|
+
description: "Equity"
|
|
604
|
+
}
|
|
605
|
+
has_equity_category.variables << var_data unless has_equity_category.variables.any? { |v| v[:name] == :equity }
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def find_category_by_type(type)
|
|
611
|
+
# Recursively search for category with given type
|
|
612
|
+
@categories.each do |category|
|
|
613
|
+
found = find_category_by_type_recursive(category, type)
|
|
614
|
+
return found if found
|
|
615
|
+
end
|
|
616
|
+
nil
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def find_category_by_type_recursive(category, type)
|
|
620
|
+
return category if category.type == type
|
|
621
|
+
|
|
622
|
+
category.children.each do |child|
|
|
623
|
+
found = find_category_by_type_recursive(child, type)
|
|
624
|
+
return found if found
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
nil
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def validate_category_variables!
|
|
631
|
+
# Validate that ALL variables in income/expense categories have account mappings
|
|
632
|
+
@categories.each do |category|
|
|
633
|
+
validate_category_variables_recursive(category)
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def validate_category_variables_recursive(category)
|
|
638
|
+
# Skip driver categories
|
|
639
|
+
return if category.type == :driver
|
|
640
|
+
|
|
641
|
+
# Check variables in this category
|
|
642
|
+
category.variables.each do |var_data|
|
|
643
|
+
# Variables in income/expense categories that generate transactions must have accounts
|
|
644
|
+
# This includes:
|
|
645
|
+
# - Calculated variables (always need accounts for transactions)
|
|
646
|
+
# - Expense variables with periodic frequency (quarterly, monthly) that generate transactions
|
|
647
|
+
# - Income variables: only calculated variables need accounts (regular income vars might be calculation-only)
|
|
648
|
+
# Annual frequency variables might be calculation-only totals, so we don't require accounts for them
|
|
649
|
+
if [:income, :expense].include?(category.type)
|
|
650
|
+
# Check if this variable is meant to generate transactions
|
|
651
|
+
# For expenses: calculated variables and periodic frequency (quarterly, monthly) need accounts
|
|
652
|
+
# For income: only calculated variables need accounts (regular income vars might be calculation-only)
|
|
653
|
+
needs_account = if category.type == :expense
|
|
654
|
+
var_data[:type] == :calculated || [:quarterly, :monthly, :weekly, :daily].include?(var_data[:frequency])
|
|
655
|
+
else # income
|
|
656
|
+
var_data[:type] == :calculated
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
if needs_account
|
|
660
|
+
# Must have at least one account specified
|
|
661
|
+
# For income: credit_account (or account which becomes credit_account)
|
|
662
|
+
# For expense: debit_account (or account which becomes debit_account)
|
|
663
|
+
# The other side will be determined automatically by transaction generator
|
|
664
|
+
has_account = (var_data[:debit_account] || var_data[:credit_account] || var_data[:account])
|
|
665
|
+
unless has_account
|
|
666
|
+
var_type_label = var_data[:type] == :calculated ? "Calculated variable" : "Variable"
|
|
667
|
+
frequency_info = var_data[:frequency] ? " (frequency: #{var_data[:frequency]})" : ""
|
|
668
|
+
raise ArgumentError.new(
|
|
669
|
+
"#{var_type_label} '#{var_data[:name]}' in #{category.type} category#{frequency_info} " \
|
|
670
|
+
"must specify an account to generate transactions. Use 'account:', 'debit_account:', or 'credit_account:' " \
|
|
671
|
+
"when defining the variable."
|
|
672
|
+
)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Auto-create accounts if they don't exist (for income/expense categories)
|
|
676
|
+
if var_data[:debit_account]
|
|
677
|
+
var_data[:debit_account] = ensure_account_exists(var_data[:debit_account], category.type)
|
|
678
|
+
end
|
|
679
|
+
if var_data[:credit_account]
|
|
680
|
+
var_data[:credit_account] = ensure_account_exists(var_data[:credit_account], category.type)
|
|
681
|
+
end
|
|
682
|
+
if var_data[:account]
|
|
683
|
+
var_data[:account] = ensure_account_exists(var_data[:account], category.type)
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Recursively check subcategories
|
|
690
|
+
category.children.each do |child|
|
|
691
|
+
validate_category_variables_recursive(child)
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Helper to check config values
|
|
696
|
+
def with_bonus?
|
|
697
|
+
@config[:with_bonus] == true
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Ensure account exists, creating it if necessary
|
|
701
|
+
# Supports array notation for nested accounts (e.g., [:assets, :checking] or [:liabilities, :accounts_payable])
|
|
702
|
+
# account_ref: Symbol or Array representing the account
|
|
703
|
+
# category_type: The type of category (:income, :expense, etc.) to determine default account type
|
|
704
|
+
def ensure_account_exists(account_ref, category_type)
|
|
705
|
+
# Extract actual account name and determine account type
|
|
706
|
+
if account_ref.is_a?(Array)
|
|
707
|
+
# Validate hierarchy: first element must be a top-level category of type :asset, :liability, or :equity
|
|
708
|
+
top_level_category_name = account_ref.first.to_sym
|
|
709
|
+
top_level_category = @categories.find { |c| c.name == top_level_category_name && c.parent.nil? }
|
|
710
|
+
|
|
711
|
+
unless top_level_category && [:asset, :liability, :equity].include?(top_level_category.type)
|
|
712
|
+
raise ArgumentError, "Account hierarchy must start with a top-level category of type :asset, :liability, or :equity. Got: #{top_level_category_name}"
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Extract account name (last element) and determine type from category
|
|
716
|
+
actual_account_name = account_ref.last.to_sym
|
|
717
|
+
account_type = top_level_category.type
|
|
718
|
+
else
|
|
719
|
+
# Symbol notation: default to :asset for income/expense variables
|
|
720
|
+
actual_account_name = account_ref.to_sym
|
|
721
|
+
account_type = :asset # Default for income/expense categories
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Check if account already exists
|
|
725
|
+
return actual_account_name if @accounts.key?(actual_account_name)
|
|
726
|
+
|
|
727
|
+
# Create the account
|
|
728
|
+
new_account = Account.new(
|
|
729
|
+
actual_account_name,
|
|
730
|
+
type: account_type,
|
|
731
|
+
currency: @config[:default_currency] || 'USD',
|
|
732
|
+
opening_balance: 0,
|
|
733
|
+
opening_balance_credit_account: :equity
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
@accounts[actual_account_name] = new_account
|
|
737
|
+
|
|
738
|
+
# Add account to appropriate category
|
|
739
|
+
ensure_account_in_category(actual_account_name, account_type, account_ref)
|
|
740
|
+
|
|
741
|
+
actual_account_name
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# Ensure account is added to appropriate category
|
|
745
|
+
# account_name: Symbol name of the account
|
|
746
|
+
# account_type: Type of account (:asset, :liability, :equity)
|
|
747
|
+
# account_hierarchy: Original account reference (Symbol or Array)
|
|
748
|
+
def ensure_account_in_category(account_name, account_type, account_hierarchy)
|
|
749
|
+
target_category = nil
|
|
750
|
+
|
|
751
|
+
if account_hierarchy.is_a?(Array)
|
|
752
|
+
# Array hierarchy: find or create categories along the path
|
|
753
|
+
# e.g., [:assets, :checking] -> find/create :assets category, add :checking to it
|
|
754
|
+
category_path = account_hierarchy[0..-2] # All elements except the last (account name)
|
|
755
|
+
|
|
756
|
+
if category_path.empty?
|
|
757
|
+
# Just [account_name] - create top-level category
|
|
758
|
+
category_name = account_type == :asset ? :assets : (account_type == :liability ? :liabilities : :equity)
|
|
759
|
+
target_category = find_or_create_category(category_name, account_type)
|
|
760
|
+
else
|
|
761
|
+
# Navigate/create category path
|
|
762
|
+
current_parent = nil
|
|
763
|
+
category_path.each_with_index do |cat_name, idx|
|
|
764
|
+
cat_name = cat_name.to_sym
|
|
765
|
+
# Find or create this category
|
|
766
|
+
if current_parent.nil?
|
|
767
|
+
# Top-level category
|
|
768
|
+
target_category = find_or_create_category(cat_name, account_type, parent: nil)
|
|
769
|
+
else
|
|
770
|
+
# Subcategory
|
|
771
|
+
target_category = find_or_create_category(cat_name, account_type, parent: current_parent)
|
|
772
|
+
end
|
|
773
|
+
current_parent = target_category
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
else
|
|
777
|
+
# Symbol notation: find or create default top-level category
|
|
778
|
+
category_name = case account_type
|
|
779
|
+
when :asset
|
|
780
|
+
:assets
|
|
781
|
+
when :liability
|
|
782
|
+
:liabilities
|
|
783
|
+
when :equity
|
|
784
|
+
:equity
|
|
785
|
+
else
|
|
786
|
+
:assets # Default fallback
|
|
787
|
+
end
|
|
788
|
+
target_category = find_or_create_category(category_name, account_type)
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# Check if account is already in this category
|
|
792
|
+
return if target_category.variables.any? { |v| v[:name] == account_name }
|
|
793
|
+
|
|
794
|
+
# Add account as a variable to the category
|
|
795
|
+
account = @accounts[account_name]
|
|
796
|
+
var_data = {
|
|
797
|
+
name: account_name,
|
|
798
|
+
type: :financial,
|
|
799
|
+
frequency: :annual,
|
|
800
|
+
currency: account.currency,
|
|
801
|
+
account: account_name,
|
|
802
|
+
description: account.name.to_s.humanize
|
|
803
|
+
}
|
|
804
|
+
target_category.variables << var_data
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# Find or create a category
|
|
808
|
+
def find_or_create_category(name, type, parent: nil)
|
|
809
|
+
name = name.to_sym
|
|
810
|
+
|
|
811
|
+
# Try to find existing category
|
|
812
|
+
if parent.nil?
|
|
813
|
+
# Top-level category
|
|
814
|
+
category = @categories.find { |c| c.name == name && c.parent.nil? }
|
|
815
|
+
else
|
|
816
|
+
# Subcategory
|
|
817
|
+
category = parent.children.find { |c| c.name == name }
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# Create if not found
|
|
821
|
+
unless category
|
|
822
|
+
category = Categories::Category.new(name, type: type, parent: parent)
|
|
823
|
+
if parent
|
|
824
|
+
parent.children << category
|
|
825
|
+
else
|
|
826
|
+
@categories << category
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
category
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# Get or create account for a category
|
|
834
|
+
# Returns the account associated with the category
|
|
835
|
+
def get_or_create_category_account(category, parent_account: nil)
|
|
836
|
+
# Return existing account if already mapped
|
|
837
|
+
return @category_accounts[category] if @category_accounts[category]
|
|
838
|
+
|
|
839
|
+
# Determine account type from category type
|
|
840
|
+
account_type = case category.type
|
|
841
|
+
when :income
|
|
842
|
+
:income
|
|
843
|
+
when :expense
|
|
844
|
+
:expense
|
|
845
|
+
when :asset
|
|
846
|
+
:asset
|
|
847
|
+
when :liability
|
|
848
|
+
:liability
|
|
849
|
+
when :equity
|
|
850
|
+
:equity
|
|
851
|
+
else
|
|
852
|
+
raise ArgumentError, "Cannot create account for category type: #{category.type}"
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# Check if account already exists by name
|
|
856
|
+
account_name = category.name.to_sym
|
|
857
|
+
if @accounts.key?(account_name)
|
|
858
|
+
account = @accounts[account_name]
|
|
859
|
+
# Update parent if needed
|
|
860
|
+
if parent_account && account.parent != parent_account
|
|
861
|
+
account.instance_variable_set(:@parent, parent_account)
|
|
862
|
+
parent_account.children << account unless parent_account.children.include?(account)
|
|
863
|
+
end
|
|
864
|
+
@category_accounts[category] = account
|
|
865
|
+
return account
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# Create new account for category
|
|
869
|
+
account = Account.new(
|
|
870
|
+
account_name,
|
|
871
|
+
type: account_type,
|
|
872
|
+
currency: @config[:default_currency] || 'USD',
|
|
873
|
+
opening_balance: 0,
|
|
874
|
+
opening_balance_credit_account: :equity,
|
|
875
|
+
parent: parent_account
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
@accounts[account_name] = account
|
|
879
|
+
@category_accounts[category] = account
|
|
880
|
+
|
|
881
|
+
account
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
# Create account for a variable as sub-account of parent account
|
|
885
|
+
def create_variable_account(variable_name, account_type, parent_account, currency)
|
|
886
|
+
account_name = variable_name.to_sym
|
|
887
|
+
|
|
888
|
+
# Check if account already exists
|
|
889
|
+
if @accounts.key?(account_name)
|
|
890
|
+
account = @accounts[account_name]
|
|
891
|
+
# Update parent if needed
|
|
892
|
+
if account.parent != parent_account
|
|
893
|
+
account.instance_variable_set(:@parent, parent_account)
|
|
894
|
+
parent_account.children << account unless parent_account.children.include?(account)
|
|
895
|
+
end
|
|
896
|
+
return account
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
# Create new account for variable
|
|
900
|
+
account = Account.new(
|
|
901
|
+
account_name,
|
|
902
|
+
type: account_type,
|
|
903
|
+
currency: currency || @config[:default_currency] || 'USD',
|
|
904
|
+
opening_balance: 0,
|
|
905
|
+
opening_balance_credit_account: :equity,
|
|
906
|
+
parent: parent_account
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
@accounts[account_name] = account
|
|
910
|
+
|
|
911
|
+
account
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# Find default asset account or create one
|
|
915
|
+
def find_default_asset_account
|
|
916
|
+
asset_account = @accounts.values.find { |acc| acc.type == :asset }
|
|
917
|
+
return asset_account.name if asset_account
|
|
918
|
+
|
|
919
|
+
# Create default asset account
|
|
920
|
+
default_account = Account.new(
|
|
921
|
+
:default_asset,
|
|
922
|
+
type: :asset,
|
|
923
|
+
currency: @config[:default_currency] || 'USD',
|
|
924
|
+
opening_balance: 0
|
|
925
|
+
)
|
|
926
|
+
@accounts[:default_asset] = default_account
|
|
927
|
+
:default_asset
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Find equity account or return :equity
|
|
931
|
+
def find_equity_account
|
|
932
|
+
equity_account = @accounts.values.find { |acc| acc.name == :equity && acc.type == :equity }
|
|
933
|
+
equity_account ? equity_account.name : :equity
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|