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,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module DSL
|
|
5
|
+
# Builder for model templates
|
|
6
|
+
class ModelTemplateBuilder
|
|
7
|
+
def initialize(template)
|
|
8
|
+
@template = template
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def variable(name)
|
|
12
|
+
@template.add_variable(name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def default_debit_accounts(accounts)
|
|
16
|
+
@template.set_default_debit_accounts(accounts)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def default_credit_accounts(accounts)
|
|
20
|
+
@template.set_default_credit_accounts(accounts)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def calculation(&block)
|
|
24
|
+
@template.set_calculation(&block)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL builder for defining plans within FinIt.define
|
|
6
|
+
class PlanBuilder
|
|
7
|
+
def initialize(name, description: nil, start_date: nil, end_date: nil)
|
|
8
|
+
@plan = Plan.new(name, description: description, start_date: start_date, end_date: end_date)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Override a variable with a new value
|
|
12
|
+
def set(variable_name, value, start_date: nil, end_date: nil)
|
|
13
|
+
@plan.set(variable_name, value, start_date: start_date, end_date: end_date)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Multiply variable by factor
|
|
17
|
+
def scale(variable_name, factor, start_date: nil, end_date: nil)
|
|
18
|
+
@plan.scale(variable_name, factor, start_date: start_date, end_date: end_date)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Add/subtract from variable
|
|
22
|
+
def adjust(variable_name, amount, start_date: nil, end_date: nil)
|
|
23
|
+
@plan.adjust(variable_name, amount, start_date: start_date, end_date: end_date)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Replace formula for calculated variable
|
|
27
|
+
def formula(variable_name, new_formula, start_date: nil, end_date: nil)
|
|
28
|
+
@plan.formula(variable_name, new_formula, start_date: start_date, end_date: end_date)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Add a new variable
|
|
32
|
+
def add_variable(name, category:, currency: nil, &block)
|
|
33
|
+
@plan.add_variable(name, category: category, currency: currency, &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Add a new calculated variable
|
|
37
|
+
def add_calculated(name, category:, formula:, **options)
|
|
38
|
+
@plan.add_calculated(name, category: category, formula: formula, **options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set account opening balance
|
|
42
|
+
def set_opening_balance(account_name, amount)
|
|
43
|
+
@plan.set_opening_balance(account_name, amount)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build and return the plan
|
|
47
|
+
def build
|
|
48
|
+
@plan
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module DSL
|
|
5
|
+
# Resolves project inheritance for calculated variables based on their dependencies
|
|
6
|
+
class ProjectInheritanceResolver
|
|
7
|
+
def initialize(calculator)
|
|
8
|
+
@calculator = calculator
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Resolve project tag from dependencies
|
|
12
|
+
# Current logic: If ANY dependency has a project tag, inherit that project
|
|
13
|
+
# Future: Can be extended to support more complex logic (priority, intersection, etc.)
|
|
14
|
+
def resolve_project(dependencies, projects_map = nil)
|
|
15
|
+
return nil if dependencies.empty?
|
|
16
|
+
|
|
17
|
+
# Build projects map if not provided
|
|
18
|
+
projects_map ||= build_projects_map(dependencies)
|
|
19
|
+
|
|
20
|
+
# Find first dependency with a project tag
|
|
21
|
+
dependencies.each do |dep_name|
|
|
22
|
+
# Try both symbol and string keys
|
|
23
|
+
project = projects_map[dep_name] || projects_map[dep_name.to_sym] || projects_map[dep_name.to_s]
|
|
24
|
+
return project if project
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_projects_map(dependencies)
|
|
33
|
+
map = {}
|
|
34
|
+
dependencies.each do |dep_name|
|
|
35
|
+
project = @calculator.get_variable_project(dep_name)
|
|
36
|
+
# Store with both symbol and string keys for flexibility
|
|
37
|
+
map[dep_name] = project
|
|
38
|
+
map[dep_name.to_sym] = project if dep_name.is_a?(String)
|
|
39
|
+
map[dep_name.to_s] = project if dep_name.is_a?(Symbol)
|
|
40
|
+
end
|
|
41
|
+
map
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module DSL
|
|
5
|
+
# Builder for variable definitions
|
|
6
|
+
class VariableBuilder
|
|
7
|
+
def initialize(name, calculator, default_currency = nil, frequency: :annual, account: nil, project: nil)
|
|
8
|
+
@name = name
|
|
9
|
+
@calculator = calculator
|
|
10
|
+
@default_currency = default_currency # Can be nil for drivers
|
|
11
|
+
@frequency = frequency
|
|
12
|
+
@account = account
|
|
13
|
+
@project = project
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def value(amount, start_date: nil, end_date: nil)
|
|
17
|
+
@calculator.define_variable(@name, amount,
|
|
18
|
+
currency: @default_currency, # Will be nil for drivers
|
|
19
|
+
frequency: @frequency,
|
|
20
|
+
start_date: start_date,
|
|
21
|
+
end_date: end_date,
|
|
22
|
+
account: @account,
|
|
23
|
+
project: @project
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def description(text)
|
|
28
|
+
# Store description metadata
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def unit(unit_name)
|
|
32
|
+
# Store unit metadata
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def frequency(freq)
|
|
36
|
+
# Store frequency metadata
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
data/lib/fin_it/dsl.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'dsl/model_builder'
|
|
4
|
+
require_relative 'financial_model'
|
|
5
|
+
|
|
6
|
+
module FinIt
|
|
7
|
+
# Main DSL entry point
|
|
8
|
+
def self.define(default_currency: 'USD', &block)
|
|
9
|
+
builder = DSL::ModelBuilder.new(default_currency: default_currency)
|
|
10
|
+
builder.instance_eval(&block)
|
|
11
|
+
builder.build
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'money'
|
|
4
|
+
|
|
5
|
+
module FinIt
|
|
6
|
+
class FinancialModel
|
|
7
|
+
# Account balance calculations (cumulative balances up to a date)
|
|
8
|
+
module AccountBalances
|
|
9
|
+
# Get account balance at a specific date (cumulative balance)
|
|
10
|
+
def account_balance(account_name, as_of_date:)
|
|
11
|
+
account = @accounts[account_name]
|
|
12
|
+
return 0 unless account
|
|
13
|
+
|
|
14
|
+
as_of_date = parse_date(as_of_date)
|
|
15
|
+
validate_date!(as_of_date)
|
|
16
|
+
|
|
17
|
+
# Generate transactions up to as_of_date if not already generated
|
|
18
|
+
@transaction_generator.generate_transactions(as_of_date)
|
|
19
|
+
|
|
20
|
+
# Start with opening balance as Money object (all values are stored as positive)
|
|
21
|
+
balance = Money.new((account.opening_balance.to_f * 100).to_i, account.currency)
|
|
22
|
+
|
|
23
|
+
# Get all transactions affecting this account up to as_of_date
|
|
24
|
+
# Exclude opening balance transactions - they record initial state, not changes
|
|
25
|
+
account_transactions = @transaction_generator.transactions(
|
|
26
|
+
date_range: { start: @start_date, end: as_of_date },
|
|
27
|
+
account: account_name
|
|
28
|
+
).reject { |t| t[:variable] == :opening_balance }
|
|
29
|
+
|
|
30
|
+
# Apply transactions to balance using Money objects for precision
|
|
31
|
+
# Account type determines how debits/credits affect balance:
|
|
32
|
+
# All accounts store positive values. The accounting equation is: Assets = Liabilities + Equity
|
|
33
|
+
# - Assets: Debits increase (balance +=), Credits decrease (balance -=)
|
|
34
|
+
# - Liabilities: Debits decrease (balance -=), Credits increase (balance +=)
|
|
35
|
+
# - Equity: Debits decrease (balance -=), Credits increase (balance +=)
|
|
36
|
+
account_transactions.each do |transaction|
|
|
37
|
+
# Convert transaction amount to account currency if needed
|
|
38
|
+
transaction_amount = transaction[:amount]
|
|
39
|
+
if transaction_amount.is_a?(Money)
|
|
40
|
+
# Convert to account currency if different
|
|
41
|
+
if transaction_amount.currency.iso_code != account.currency
|
|
42
|
+
transaction_amount = transaction_amount.exchange_to(account.currency)
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
# Legacy: convert float to Money (shouldn't happen after refactoring)
|
|
46
|
+
transaction_amount = Money.new((transaction_amount.to_f * 100).to_i, account.currency)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if transaction[:debit_account] == account_name
|
|
50
|
+
# Debit affects balance based on account type
|
|
51
|
+
if account.type == :asset
|
|
52
|
+
balance += transaction_amount # Debits increase assets
|
|
53
|
+
else # liability or equity
|
|
54
|
+
balance -= transaction_amount # Debits decrease liabilities/equity
|
|
55
|
+
end
|
|
56
|
+
elsif transaction[:credit_account] == account_name
|
|
57
|
+
# Credit affects balance based on account type
|
|
58
|
+
if account.type == :asset
|
|
59
|
+
balance -= transaction_amount # Credits decrease assets
|
|
60
|
+
else # liability or equity
|
|
61
|
+
balance += transaction_amount # Credits increase liabilities/equity
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Return as float for backward compatibility (Money object would be better but breaks API)
|
|
67
|
+
balance.to_f
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Sum balances for multiple accounts (for balance sheets)
|
|
71
|
+
def accounts_total_balance(account_names, as_of_date:, output_currency: nil)
|
|
72
|
+
output_currency ||= @config[:default_currency]
|
|
73
|
+
as_of_date = parse_date(as_of_date)
|
|
74
|
+
validate_date!(as_of_date)
|
|
75
|
+
|
|
76
|
+
total = Money.new(0, output_currency)
|
|
77
|
+
|
|
78
|
+
Array(account_names).each do |account_name|
|
|
79
|
+
account = @accounts[account_name]
|
|
80
|
+
next unless account
|
|
81
|
+
|
|
82
|
+
# Use account balance at as_of_date
|
|
83
|
+
balance = account_balance(account_name, as_of_date: as_of_date)
|
|
84
|
+
account_money = Money.new((balance * 100).to_i, account.currency)
|
|
85
|
+
|
|
86
|
+
# Convert to output currency if needed
|
|
87
|
+
if account_money.currency.iso_code != output_currency
|
|
88
|
+
account_money = account_money.exchange_to(output_currency)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
total += account_money
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
total.to_f
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
class FinancialModel
|
|
5
|
+
# Account hierarchy query methods
|
|
6
|
+
module AccountHierarchy
|
|
7
|
+
# Get account hierarchy (account with children)
|
|
8
|
+
def account_hierarchy(account_name)
|
|
9
|
+
account = @accounts[account_name]
|
|
10
|
+
return nil unless account
|
|
11
|
+
account
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Find account by hierarchical path (e.g., [:cost_of_goods_sold, :shrimp_cost_regular])
|
|
15
|
+
def find_account_by_path(path_array)
|
|
16
|
+
return nil if path_array.nil? || path_array.empty?
|
|
17
|
+
|
|
18
|
+
path_array = path_array.map(&:to_sym)
|
|
19
|
+
current_account = @accounts[path_array.first]
|
|
20
|
+
return nil unless current_account
|
|
21
|
+
|
|
22
|
+
path_array[1..-1].each do |name|
|
|
23
|
+
current_account = current_account.children.find { |child| child.name == name }
|
|
24
|
+
return nil unless current_account
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
current_account
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get direct children of an account
|
|
31
|
+
def account_children(account_name)
|
|
32
|
+
account = @accounts[account_name]
|
|
33
|
+
return [] unless account
|
|
34
|
+
account.children
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get all descendants of an account recursively
|
|
38
|
+
def account_descendants(account_name)
|
|
39
|
+
account = @accounts[account_name]
|
|
40
|
+
return [] unless account
|
|
41
|
+
account.descendants
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get account for a category (if category-to-account mapping exists)
|
|
45
|
+
def category_account(category)
|
|
46
|
+
# Use category_accounts mapping if available
|
|
47
|
+
return @category_accounts[category] if @category_accounts && @category_accounts[category]
|
|
48
|
+
|
|
49
|
+
# Fallback: try to find account by category name
|
|
50
|
+
account_name = category.name.to_sym
|
|
51
|
+
return @accounts[account_name] if @accounts.key?(account_name)
|
|
52
|
+
|
|
53
|
+
# If not found, search for account with matching name
|
|
54
|
+
# This handles cases where category and account have same name
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get category for an account (reverse lookup)
|
|
59
|
+
def account_category(account_name)
|
|
60
|
+
account = @accounts[account_name]
|
|
61
|
+
return nil unless account
|
|
62
|
+
|
|
63
|
+
# Search categories for one matching account name
|
|
64
|
+
@categories.find { |cat| cat.name == account.name }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Calculate total for account including all children
|
|
68
|
+
# For income/expense: uses period flow
|
|
69
|
+
# For asset/liability/equity: uses balance
|
|
70
|
+
def account_total(account_name, start_date, end_date, use_balance: false, output_currency: nil)
|
|
71
|
+
account = @accounts[account_name]
|
|
72
|
+
return 0 unless account
|
|
73
|
+
|
|
74
|
+
output_currency ||= @config[:default_currency]
|
|
75
|
+
start_date = parse_date(start_date)
|
|
76
|
+
end_date = parse_date(end_date)
|
|
77
|
+
|
|
78
|
+
# If account has children, sum children totals
|
|
79
|
+
if account.children.any?
|
|
80
|
+
total = account.children.sum do |child|
|
|
81
|
+
account_total(child.name, start_date, end_date, use_balance: use_balance, output_currency: output_currency)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Convert to output currency if needed
|
|
85
|
+
if account.currency != output_currency
|
|
86
|
+
total_money = Money.new((total * 100).to_i, account.currency)
|
|
87
|
+
total = @calculator.convert_currency(total_money, output_currency).to_f
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
total
|
|
91
|
+
else
|
|
92
|
+
# Leaf account: use its own value
|
|
93
|
+
if use_balance
|
|
94
|
+
balance = account_balance(account_name, as_of_date: end_date)
|
|
95
|
+
# Convert to output currency if needed
|
|
96
|
+
if account.currency != output_currency
|
|
97
|
+
balance_money = Money.new((balance * 100).to_i, account.currency)
|
|
98
|
+
balance = @calculator.convert_currency(balance_money, output_currency).to_f
|
|
99
|
+
end
|
|
100
|
+
balance
|
|
101
|
+
else
|
|
102
|
+
flow = account_period_flow(account_name, start_date, end_date)
|
|
103
|
+
# Convert to output currency if needed
|
|
104
|
+
if account.currency != output_currency
|
|
105
|
+
flow_money = Money.new((flow * 100).to_i, account.currency)
|
|
106
|
+
flow = @calculator.convert_currency(flow_money, output_currency).to_f
|
|
107
|
+
end
|
|
108
|
+
flow
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get period flow for account including all children
|
|
114
|
+
def account_period_flow_with_children(account_name, start_date, end_date, output_currency: nil)
|
|
115
|
+
account_total(account_name, start_date, end_date, use_balance: false, output_currency: output_currency)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get balance for account including all children
|
|
119
|
+
def account_balance_with_children(account_name, as_of_date, output_currency: nil)
|
|
120
|
+
account_total(account_name, as_of_date, as_of_date, use_balance: true, output_currency: output_currency)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Calculate category total via account hierarchy
|
|
124
|
+
def category_total_via_account(category, start_date, end_date, period_type: nil, output_currency: nil, filters: {}, use_balance: false)
|
|
125
|
+
# Get account for category
|
|
126
|
+
account = category_account(category)
|
|
127
|
+
|
|
128
|
+
if account
|
|
129
|
+
# Use account hierarchy for calculation
|
|
130
|
+
# If filters are applied, we need to filter by project at the account level
|
|
131
|
+
# For now, use the account total (filtering by project would require transaction-level filtering)
|
|
132
|
+
total = account_total(account.name, start_date, end_date, use_balance: use_balance, output_currency: output_currency)
|
|
133
|
+
|
|
134
|
+
# Apply project filter if needed (filter transactions by variable project)
|
|
135
|
+
if filters[:project] && account.children.any?
|
|
136
|
+
# Filter children accounts by their variable's project
|
|
137
|
+
filtered_total = account.children.sum do |child_account|
|
|
138
|
+
# Check if child account's variable matches project filter
|
|
139
|
+
child_var = category.variables.find { |v| v[:name] == child_account.name }
|
|
140
|
+
if child_var && variable_matches_project?(child_var, filters[:project])
|
|
141
|
+
account_total(child_account.name, start_date, end_date, use_balance: use_balance, output_currency: output_currency)
|
|
142
|
+
else
|
|
143
|
+
0
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
return filtered_total
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
total
|
|
150
|
+
else
|
|
151
|
+
# Fallback to category-based calculation (backward compatibility)
|
|
152
|
+
category_total_for_period(category, start_date, end_date, period_type: period_type, output_currency: output_currency, filters: filters)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'money'
|
|
4
|
+
|
|
5
|
+
module FinIt
|
|
6
|
+
class FinancialModel
|
|
7
|
+
# Category variable value calculations
|
|
8
|
+
module CategoryValues
|
|
9
|
+
# Calculate total for a category and its descendants for a period
|
|
10
|
+
# Handles account-based variables using account_period_flow() or account_balance()
|
|
11
|
+
# Handles non-account variables using calculator.calculate() with period_type
|
|
12
|
+
# Category totals are calculated as: sum of children category totals (if children exist),
|
|
13
|
+
# otherwise sum of direct variable values
|
|
14
|
+
# With hierarchical accounts: prefers account-based calculation if category has an account
|
|
15
|
+
def category_total_for_period(category, start_date, end_date, period_type: nil, output_currency: nil, filters: {}, use_account_transactions_only: false)
|
|
16
|
+
output_currency ||= @config[:default_currency]
|
|
17
|
+
period_type ||= determine_period_type(start_date, end_date)
|
|
18
|
+
|
|
19
|
+
start_date = parse_date(start_date)
|
|
20
|
+
end_date = parse_date(end_date)
|
|
21
|
+
# Don't validate dates here - allow reports to query any date range
|
|
22
|
+
# Use max of start_date and model start_date for actual calculations
|
|
23
|
+
start_date = [start_date, @start_date].max
|
|
24
|
+
end_date = [end_date, @start_date].max
|
|
25
|
+
|
|
26
|
+
# Try account-based calculation first (hierarchical accounts)
|
|
27
|
+
# Use AccountHierarchy methods via self (since both modules are included in FinancialModel)
|
|
28
|
+
if respond_to?(:category_account)
|
|
29
|
+
category_account = category_account(category)
|
|
30
|
+
if category_account
|
|
31
|
+
use_balance = [:asset, :liability, :equity].include?(category.type)
|
|
32
|
+
return category_total_via_account(category, start_date, end_date,
|
|
33
|
+
period_type: period_type, output_currency: output_currency,
|
|
34
|
+
filters: filters, use_balance: use_balance)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Fallback to variable-based calculation (backward compatibility)
|
|
39
|
+
# If bypassing category hierarchy, calculate only from direct account transactions
|
|
40
|
+
if use_account_transactions_only
|
|
41
|
+
# Filter variables by project if filter is set
|
|
42
|
+
variables = category.variables
|
|
43
|
+
if filters[:project]
|
|
44
|
+
variables = variables.select { |var| variable_matches_project?(var, filters[:project]) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return variables.sum do |var|
|
|
48
|
+
category_variable_value(var, end_date, period_type: period_type, output_currency: output_currency, category_type: category.type, start_date: start_date)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Category total calculation logic:
|
|
53
|
+
# If category has children → total = sum of children category totals (recursive)
|
|
54
|
+
# If category has no children → total = sum of direct variable values
|
|
55
|
+
# This prevents double counting (children totals already include their variables)
|
|
56
|
+
|
|
57
|
+
if category.children.any?
|
|
58
|
+
# Has children: sum of children category totals
|
|
59
|
+
category.children.sum do |child|
|
|
60
|
+
category_total_for_period(child, start_date, end_date, period_type: period_type, output_currency: output_currency, filters: filters, use_account_transactions_only: false)
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
# No children: sum of direct variable values
|
|
64
|
+
variables = category.variables
|
|
65
|
+
if filters[:project]
|
|
66
|
+
variables = variables.select { |var| variable_matches_project?(var, filters[:project]) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
variables.sum do |var|
|
|
70
|
+
category_variable_value(var, end_date, period_type: period_type, output_currency: output_currency, category_type: category.type, start_date: start_date)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get value for a single variable in a category context
|
|
76
|
+
# Checks if variable maps to account, uses appropriate method
|
|
77
|
+
# For income/expense: uses account_period_flow() (period flow, not cumulative)
|
|
78
|
+
# For balance sheet: uses account_balance() (cumulative balance)
|
|
79
|
+
def category_variable_value(var_data, date, period_type: :annual, output_currency: nil, category_type: nil, start_date: nil)
|
|
80
|
+
output_currency ||= @config[:default_currency]
|
|
81
|
+
date = parse_date(date)
|
|
82
|
+
# Don't validate date here - allow reports to query any date range
|
|
83
|
+
# Validation happens when generating transactions
|
|
84
|
+
|
|
85
|
+
# Check if variable maps to account
|
|
86
|
+
account_name = var_data[:account] || var_data[:debit_account] || var_data[:credit_account]
|
|
87
|
+
|
|
88
|
+
if account_name && @accounts[account_name]
|
|
89
|
+
# Use account-based calculation
|
|
90
|
+
account = @accounts[account_name]
|
|
91
|
+
|
|
92
|
+
# For income statements: use account_period_flow
|
|
93
|
+
# For balance sheets: use account_balance
|
|
94
|
+
if category_type && [:income, :expense].include?(category_type)
|
|
95
|
+
# Income/expense categories: use period flow FOR THIS SPECIFIC VARIABLE
|
|
96
|
+
# Use provided start_date or model start_date as fallback
|
|
97
|
+
period_start = start_date || @start_date
|
|
98
|
+
period_start = parse_date(period_start)
|
|
99
|
+
# Don't validate period_start - use max of period_start and model start_date
|
|
100
|
+
period_start = [period_start, @start_date].max
|
|
101
|
+
|
|
102
|
+
# Filter transactions by variable name to get flow for this specific variable only
|
|
103
|
+
# Use transaction-based flow directly - no overrides for quarterly fees
|
|
104
|
+
flow = account_period_flow(account_name, period_start, date, variable: var_data[:name])
|
|
105
|
+
|
|
106
|
+
# For income categories: positive flow is income
|
|
107
|
+
# For expense categories: flow direction depends on account type
|
|
108
|
+
# - Asset accounts: Credit (negative flow) = expense, so use -flow
|
|
109
|
+
# - Liability accounts: Credit (positive flow) = expense, so use flow
|
|
110
|
+
# - Equity accounts: Debit (negative flow) = expense, so use -flow
|
|
111
|
+
if category_type == :income
|
|
112
|
+
flow_value = flow > 0 ? flow : 0
|
|
113
|
+
elsif category_type == :expense
|
|
114
|
+
if account.type == :liability
|
|
115
|
+
# Liability accounts: credit = positive flow = expense
|
|
116
|
+
flow_value = flow > 0 ? flow : 0
|
|
117
|
+
else
|
|
118
|
+
# Asset/Equity accounts: credit/debit = negative flow = expense
|
|
119
|
+
flow_value = flow < 0 ? -flow : 0
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
flow_value = flow
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Convert to output currency if needed
|
|
126
|
+
if account.currency != output_currency
|
|
127
|
+
flow_money = Money.new((flow_value * 100).to_i, account.currency)
|
|
128
|
+
flow_value = @calculator.convert_currency(flow_money, output_currency).to_f
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
flow_value
|
|
132
|
+
else
|
|
133
|
+
# Balance sheet categories: use account balance
|
|
134
|
+
balance = account_balance(account_name, as_of_date: date)
|
|
135
|
+
|
|
136
|
+
# Convert to output currency if needed
|
|
137
|
+
if account.currency != output_currency
|
|
138
|
+
balance_money = Money.new((balance * 100).to_i, account.currency)
|
|
139
|
+
balance = @calculator.convert_currency(balance_money, output_currency).to_f
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
balance
|
|
143
|
+
end
|
|
144
|
+
else
|
|
145
|
+
# Use standard variable calculation (no account mapping)
|
|
146
|
+
# Calculator already handles period scaling via period_type parameter
|
|
147
|
+
var_value = @calculator.calculate(
|
|
148
|
+
var_data[:name],
|
|
149
|
+
date: date,
|
|
150
|
+
output_currency: output_currency,
|
|
151
|
+
period_type: period_type
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
var_value.is_a?(Money) ? var_value.to_f : (var_value || 0)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Sum flows/balances for multiple accounts
|
|
159
|
+
# For income statements: uses account_period_flow() for each account
|
|
160
|
+
# For balance sheets: uses account_balance() for each account
|
|
161
|
+
def accounts_total_for_period(account_names, start_date, end_date, account_type: nil, output_currency: nil, use_balance: false)
|
|
162
|
+
output_currency ||= @config[:default_currency]
|
|
163
|
+
start_date = parse_date(start_date)
|
|
164
|
+
end_date = parse_date(end_date)
|
|
165
|
+
validate_date!(start_date)
|
|
166
|
+
validate_date!(end_date)
|
|
167
|
+
|
|
168
|
+
if use_balance
|
|
169
|
+
# For balance sheets: use account balance at end_date
|
|
170
|
+
accounts_total_balance(account_names, as_of_date: end_date, output_currency: output_currency)
|
|
171
|
+
else
|
|
172
|
+
# For income statements: use account period flow
|
|
173
|
+
accounts_total_flow(account_names, start_date, end_date, output_currency: output_currency)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
class FinancialModel
|
|
5
|
+
# Currency conversion and scaling utilities
|
|
6
|
+
# Note: Period scaling is handled by Calculator via TemporalValue#value_at
|
|
7
|
+
# This module is kept for future currency-related helper methods
|
|
8
|
+
module CurrencyHelpers
|
|
9
|
+
# Placeholder for future currency helper methods
|
|
10
|
+
# Period scaling is handled by Calculator, not here
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|