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,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require_relative 'calculator'
|
|
5
|
+
|
|
6
|
+
module FinIt
|
|
7
|
+
# Complex models with coded formulas (e.g., loans, amortization schedules)
|
|
8
|
+
# These models can define input variables and calculate their own transactions
|
|
9
|
+
class ComplexModel
|
|
10
|
+
attr_reader :name, :start_date, :end_date, :frequency, :input_variables, :formula_block, :debit_account, :credit_account, :project
|
|
11
|
+
|
|
12
|
+
def initialize(name, start_date:, end_date: nil, frequency: :monthly, debit_account:, credit_account:, project: nil, &formula_block)
|
|
13
|
+
@name = name
|
|
14
|
+
@start_date = parse_date(start_date)
|
|
15
|
+
@end_date = parse_date(end_date)
|
|
16
|
+
@frequency = frequency
|
|
17
|
+
@debit_account = debit_account
|
|
18
|
+
@credit_account = credit_account
|
|
19
|
+
@project = project
|
|
20
|
+
@formula_block = formula_block
|
|
21
|
+
@input_variables = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Define an input variable for this model
|
|
25
|
+
def input_variable(name, value)
|
|
26
|
+
@input_variables[name] = value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Calculate transactions for this model up to end_date
|
|
30
|
+
def calculate_transactions(end_date, calculator, model_context = {})
|
|
31
|
+
end_date = parse_date(end_date)
|
|
32
|
+
transactions = []
|
|
33
|
+
|
|
34
|
+
# Generate dates based on frequency
|
|
35
|
+
dates = generate_dates(@start_date, [@end_date, end_date].compact.min, @frequency)
|
|
36
|
+
|
|
37
|
+
dates.each do |date|
|
|
38
|
+
# Build context with input variables and model context
|
|
39
|
+
context = @input_variables.merge(model_context)
|
|
40
|
+
|
|
41
|
+
# Execute formula block - can return single amount or array of transactions
|
|
42
|
+
result = @formula_block.call(date, context)
|
|
43
|
+
|
|
44
|
+
next unless result
|
|
45
|
+
|
|
46
|
+
# Handle array of transactions (multiple transactions per period)
|
|
47
|
+
if result.is_a?(Array)
|
|
48
|
+
result.each do |txn_hash|
|
|
49
|
+
next unless txn_hash.is_a?(Hash) && txn_hash[:amount]
|
|
50
|
+
|
|
51
|
+
# Convert to Money object for precision and currency safety
|
|
52
|
+
amount = if txn_hash[:amount].is_a?(Money)
|
|
53
|
+
txn_hash[:amount]
|
|
54
|
+
else
|
|
55
|
+
currency = txn_hash[:currency] || 'USD'
|
|
56
|
+
Money.new((txn_hash[:amount].to_f * 100).to_i, currency)
|
|
57
|
+
end
|
|
58
|
+
next if amount.zero?
|
|
59
|
+
|
|
60
|
+
transactions << {
|
|
61
|
+
date: date,
|
|
62
|
+
variable: @name,
|
|
63
|
+
amount: amount, # Money object
|
|
64
|
+
debit_account: txn_hash[:debit_account] || @debit_account,
|
|
65
|
+
credit_account: txn_hash[:credit_account] || @credit_account,
|
|
66
|
+
description: txn_hash[:description] || "Transaction for #{@name}",
|
|
67
|
+
currency: amount.currency.iso_code,
|
|
68
|
+
model_type: :complex
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
else
|
|
72
|
+
# Single transaction (backward compatibility)
|
|
73
|
+
# Convert to Money object for precision and currency safety
|
|
74
|
+
amount = if result.is_a?(Money)
|
|
75
|
+
result
|
|
76
|
+
else
|
|
77
|
+
Money.new((result.to_f * 100).to_i, 'USD')
|
|
78
|
+
end
|
|
79
|
+
next if amount.zero?
|
|
80
|
+
|
|
81
|
+
transactions << {
|
|
82
|
+
date: date,
|
|
83
|
+
variable: @name,
|
|
84
|
+
amount: amount, # Money object
|
|
85
|
+
debit_account: @debit_account,
|
|
86
|
+
credit_account: @credit_account,
|
|
87
|
+
description: "Transaction for #{@name}",
|
|
88
|
+
currency: amount.currency.iso_code,
|
|
89
|
+
model_type: :complex
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
transactions
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Calculate hash for this model (for cache invalidation)
|
|
98
|
+
def hash(calculator)
|
|
99
|
+
# Hash = hash of input variables + model definition
|
|
100
|
+
input_hash = @input_variables.sort_by { |k, _| k.to_s }
|
|
101
|
+
.map { |k, v| "#{k}:#{hash_value(v)}" }
|
|
102
|
+
.join('|')
|
|
103
|
+
|
|
104
|
+
model_hash_data = [
|
|
105
|
+
@name.to_s,
|
|
106
|
+
@start_date.to_s,
|
|
107
|
+
@end_date&.to_s,
|
|
108
|
+
@frequency.to_s,
|
|
109
|
+
@debit_account.to_s,
|
|
110
|
+
@credit_account.to_s,
|
|
111
|
+
@project&.to_s
|
|
112
|
+
].compact.join('|')
|
|
113
|
+
|
|
114
|
+
require 'digest'
|
|
115
|
+
Digest::SHA256.hexdigest("#{input_hash}|#{model_hash_data}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def generate_dates(start_date, end_date, frequency)
|
|
121
|
+
dates = []
|
|
122
|
+
current = start_date
|
|
123
|
+
|
|
124
|
+
while current <= end_date
|
|
125
|
+
dates << current
|
|
126
|
+
|
|
127
|
+
current = case frequency
|
|
128
|
+
when :daily
|
|
129
|
+
current + 1
|
|
130
|
+
when :weekly
|
|
131
|
+
current + 7
|
|
132
|
+
when :monthly
|
|
133
|
+
current >> 1
|
|
134
|
+
when :quarterly
|
|
135
|
+
current >> 3
|
|
136
|
+
when :annual
|
|
137
|
+
current >> 12
|
|
138
|
+
else
|
|
139
|
+
current >> 1 # Default monthly
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
dates
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def hash_value(value)
|
|
147
|
+
if value.is_a?(Money)
|
|
148
|
+
"#{value.fractional}:#{value.currency.iso_code}"
|
|
149
|
+
else
|
|
150
|
+
value.to_s
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_date(date)
|
|
155
|
+
return date if date.is_a?(Date)
|
|
156
|
+
return nil if date.nil?
|
|
157
|
+
|
|
158
|
+
case date
|
|
159
|
+
when String
|
|
160
|
+
Date.parse(date)
|
|
161
|
+
when Time
|
|
162
|
+
date.to_date
|
|
163
|
+
else
|
|
164
|
+
date
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module DSL
|
|
5
|
+
# Builder for account definitions
|
|
6
|
+
class AccountBuilder
|
|
7
|
+
attr_reader :account_type, :account_currency, :account_opening_balance, :account_opening_balance_credit_account
|
|
8
|
+
|
|
9
|
+
def initialize(name)
|
|
10
|
+
@name = name
|
|
11
|
+
@account_type = nil
|
|
12
|
+
@account_currency = nil
|
|
13
|
+
@account_opening_balance = nil
|
|
14
|
+
@account_opening_balance_credit_account = :equity
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def type(account_type)
|
|
18
|
+
@account_type = account_type
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def currency(currency_code)
|
|
22
|
+
@account_currency = currency_code
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def opening_balance(amount)
|
|
26
|
+
@account_opening_balance = amount
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def opening_balance_credit_account(account_name)
|
|
30
|
+
@account_opening_balance_credit_account = account_name
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module DSL
|
|
5
|
+
# Builder for calculated variables
|
|
6
|
+
class CalculatedBuilder
|
|
7
|
+
def initialize(name, calculator)
|
|
8
|
+
@name = name
|
|
9
|
+
@calculator = calculator
|
|
10
|
+
@formula = nil
|
|
11
|
+
@round_to = nil
|
|
12
|
+
@start_date = nil
|
|
13
|
+
@end_date = nil
|
|
14
|
+
@frequency = nil
|
|
15
|
+
@payment_schedule = nil
|
|
16
|
+
@debit_account = nil
|
|
17
|
+
@credit_account = nil
|
|
18
|
+
@account = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def formula(expression = nil)
|
|
22
|
+
if expression
|
|
23
|
+
@formula = expression
|
|
24
|
+
else
|
|
25
|
+
@formula
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def round_to(digits = nil)
|
|
30
|
+
if digits
|
|
31
|
+
@round_to = digits
|
|
32
|
+
else
|
|
33
|
+
@round_to
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def start_date(date = nil)
|
|
38
|
+
if date
|
|
39
|
+
@start_date = date
|
|
40
|
+
else
|
|
41
|
+
@start_date
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def end_date(date = nil)
|
|
46
|
+
if date
|
|
47
|
+
@end_date = date
|
|
48
|
+
else
|
|
49
|
+
@end_date
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def frequency(freq = nil)
|
|
54
|
+
if freq
|
|
55
|
+
@frequency = freq
|
|
56
|
+
else
|
|
57
|
+
@frequency
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def payment_schedule(schedule = nil)
|
|
62
|
+
if schedule
|
|
63
|
+
@payment_schedule = schedule
|
|
64
|
+
else
|
|
65
|
+
@payment_schedule
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def description(text = nil)
|
|
70
|
+
# Store description
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def debit_account(account)
|
|
74
|
+
@debit_account = account
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def credit_account(account)
|
|
78
|
+
@credit_account = account
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def account(account)
|
|
82
|
+
@account = account
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module DSL
|
|
5
|
+
# Builder for configuration
|
|
6
|
+
class ConfigBuilder
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def with_bonus(value)
|
|
12
|
+
@config[:with_bonus] = value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def base_year(year)
|
|
16
|
+
@config[:base_year] = year
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def default_currency(currency)
|
|
20
|
+
@config[:default_currency] = currency
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start_date(date_or_year)
|
|
24
|
+
# Accept year number (Integer) or Date
|
|
25
|
+
if date_or_year.is_a?(Integer)
|
|
26
|
+
@config[:start_date] = Date.new(date_or_year, 1, 1)
|
|
27
|
+
elsif date_or_year.is_a?(Date)
|
|
28
|
+
@config[:start_date] = date_or_year
|
|
29
|
+
elsif date_or_year.is_a?(String)
|
|
30
|
+
@config[:start_date] = Date.parse(date_or_year)
|
|
31
|
+
else
|
|
32
|
+
raise ArgumentError, "start_date must be a year (Integer), Date, or date string, got #{date_or_year.class}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exchange_rates(rates)
|
|
37
|
+
rates.each do |from, to_rates|
|
|
38
|
+
to_rates.each do |to, rate|
|
|
39
|
+
Money.add_rate(from.to_s, to.to_s, rate)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def method_missing(name, *args)
|
|
45
|
+
if args.length == 1
|
|
46
|
+
@config[name] = args.first
|
|
47
|
+
else
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def respond_to_missing?(name, include_private = false)
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|