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.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +24 -0
  3. data/CHANGELOG.md +9 -0
  4. data/CONTRIBUTING.md +20 -0
  5. data/LICENSE +21 -0
  6. data/QUICKSTART.md +56 -0
  7. data/README.md +74 -0
  8. data/Rakefile +23 -0
  9. data/SECURITY.md +14 -0
  10. data/assets/fin_it_logo.png +0 -0
  11. data/lib/fin_it/account.rb +120 -0
  12. data/lib/fin_it/calculator/currency_conversion.rb +27 -0
  13. data/lib/fin_it/calculator/date_helpers.rb +53 -0
  14. data/lib/fin_it/calculator/variable_hashing.rb +120 -0
  15. data/lib/fin_it/calculator.rb +480 -0
  16. data/lib/fin_it/categories/category.rb +137 -0
  17. data/lib/fin_it/complex_model.rb +169 -0
  18. data/lib/fin_it/dsl/account_builder.rb +35 -0
  19. data/lib/fin_it/dsl/calculated_builder.rb +87 -0
  20. data/lib/fin_it/dsl/config_builder.rb +58 -0
  21. data/lib/fin_it/dsl/model_builder.rb +938 -0
  22. data/lib/fin_it/dsl/model_template_builder.rb +29 -0
  23. data/lib/fin_it/dsl/plan_builder.rb +52 -0
  24. data/lib/fin_it/dsl/project_inheritance_resolver.rb +46 -0
  25. data/lib/fin_it/dsl/variable_builder.rb +41 -0
  26. data/lib/fin_it/dsl.rb +13 -0
  27. data/lib/fin_it/engine.rb +15 -0
  28. data/lib/fin_it/financial_model/account_balances.rb +99 -0
  29. data/lib/fin_it/financial_model/account_hierarchy.rb +158 -0
  30. data/lib/fin_it/financial_model/category_values.rb +179 -0
  31. data/lib/fin_it/financial_model/currency_helpers.rb +14 -0
  32. data/lib/fin_it/financial_model/date_helpers.rb +58 -0
  33. data/lib/fin_it/financial_model/debugging.rb +353 -0
  34. data/lib/fin_it/financial_model/period_flows.rb +121 -0
  35. data/lib/fin_it/financial_model/validation.rb +85 -0
  36. data/lib/fin_it/financial_model/variable_matching.rb +49 -0
  37. data/lib/fin_it/financial_model.rb +395 -0
  38. data/lib/fin_it/model_template.rb +121 -0
  39. data/lib/fin_it/outputs/base_output.rb +51 -0
  40. data/lib/fin_it/outputs/console_output.rb +1528 -0
  41. data/lib/fin_it/outputs/monthly_console_output.rb +145 -0
  42. data/lib/fin_it/outputs/zaxcel_output.rb +1264 -0
  43. data/lib/fin_it/payment_schedule.rb +112 -0
  44. data/lib/fin_it/plan.rb +159 -0
  45. data/lib/fin_it/reports/balance_sheet.rb +638 -0
  46. data/lib/fin_it/reports/base_report.rb +239 -0
  47. data/lib/fin_it/reports/cash_flow_statement.rb +480 -0
  48. data/lib/fin_it/reports/custom_sheet.rb +436 -0
  49. data/lib/fin_it/reports/income_statement.rb +793 -0
  50. data/lib/fin_it/reports/period_comparison.rb +309 -0
  51. data/lib/fin_it/reports/scenario_comparison.rb +296 -0
  52. data/lib/fin_it/temporal_value.rb +349 -0
  53. data/lib/fin_it/transaction_generator/account_resolver.rb +118 -0
  54. data/lib/fin_it/transaction_generator/cache_management.rb +39 -0
  55. data/lib/fin_it/transaction_generator/date_generation.rb +57 -0
  56. data/lib/fin_it/transaction_generator.rb +357 -0
  57. data/lib/fin_it/version.rb +6 -0
  58. data/lib/fin_it.rb +27 -0
  59. data/test/fin_it/calculator_test.rb +109 -0
  60. data/test/fin_it/complex_model_test.rb +198 -0
  61. data/test/fin_it/debugging_test.rb +112 -0
  62. data/test/fin_it/driver_variables_test.rb +109 -0
  63. data/test/fin_it/dsl_test.rb +581 -0
  64. data/test/fin_it/financial_model_test.rb +196 -0
  65. data/test/fin_it/frequency_test.rb +51 -0
  66. data/test/fin_it/outputs/console_output_test.rb +249 -0
  67. data/test/fin_it/plan_test.rb +281 -0
  68. data/test/fin_it/reports/account_balance_test.rb +232 -0
  69. data/test/fin_it/reports/balance_sheet_test.rb +355 -0
  70. data/test/fin_it/reports/cash_flow_statement_test.rb +234 -0
  71. data/test/fin_it/reports/custom_sheet_test.rb +246 -0
  72. data/test/fin_it/reports/income_statement_test.rb +431 -0
  73. data/test/fin_it/reports/period_comparison_test.rb +226 -0
  74. data/test/fin_it/reports/restaurant_model_test.rb +225 -0
  75. data/test/fin_it/reports/scenario_comparison_test.rb +414 -0
  76. data/test/scripts/generate_demo_reports.rb +47 -0
  77. data/test/scripts/startup_saas_demo.rb +62 -0
  78. data/test/test_helper.rb +25 -0
  79. data/test/verify_accounting_equation.rb +91 -0
  80. 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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module FinIt
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace FinIt
8
+
9
+ config.generators do |g|
10
+ g.test_framework :minitest
11
+ g.fixture_replacement :factory_bot
12
+ end
13
+ end
14
+ end
15
+
@@ -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
+