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,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module Reports
|
|
5
|
+
class BaseReport
|
|
6
|
+
attr_reader :model, :start_date, :end_date, :output_currency, :filters
|
|
7
|
+
|
|
8
|
+
def initialize(model, start_date:, end_date:, output_currency: 'USD', filters: {})
|
|
9
|
+
@model = model
|
|
10
|
+
@start_date = start_date
|
|
11
|
+
@end_date = end_date
|
|
12
|
+
@output_currency = output_currency
|
|
13
|
+
@filters = filters
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Abstract method - subclasses define which categories to include
|
|
17
|
+
def included_category_types
|
|
18
|
+
raise NotImplementedError, "Subclasses must define included_category_types"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get categories that should be in this report
|
|
22
|
+
def relevant_categories
|
|
23
|
+
@model.categories.select do |cat|
|
|
24
|
+
included_category_types.include?(cat.type) &&
|
|
25
|
+
matches_filters?(cat) &&
|
|
26
|
+
category_has_relevant_variables?(cat)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check if category has variables that match project filter
|
|
31
|
+
def category_has_relevant_variables?(category)
|
|
32
|
+
return true unless @filters[:project]
|
|
33
|
+
|
|
34
|
+
# Check if any variable in this category or its descendants matches the project
|
|
35
|
+
all_vars = category.variables + category.descendants.flat_map(&:variables)
|
|
36
|
+
return false if all_vars.empty?
|
|
37
|
+
|
|
38
|
+
all_vars.any? { |var| variable_matches_project?(var, @filters[:project]) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if a variable matches the project filter
|
|
42
|
+
def variable_matches_project?(variable, project_filter)
|
|
43
|
+
return true unless project_filter
|
|
44
|
+
|
|
45
|
+
variable_project = get_variable_project(variable[:name])
|
|
46
|
+
variable_project == project_filter
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get project tag for a variable
|
|
50
|
+
def get_variable_project(variable_name)
|
|
51
|
+
# Check in temporal values metadata
|
|
52
|
+
temporal_value = @model.calculator.instance_variable_get(:@temporal_values)[variable_name]
|
|
53
|
+
if temporal_value
|
|
54
|
+
# Get project from most recent period metadata
|
|
55
|
+
periods = temporal_value.instance_variable_get(:@periods)
|
|
56
|
+
if periods && periods.any?
|
|
57
|
+
latest_period = periods.last
|
|
58
|
+
return latest_period[:metadata][:project] if latest_period[:metadata]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check in calculated variables metadata
|
|
63
|
+
var_def = @model.calculator.variables[variable_name]
|
|
64
|
+
if var_def.is_a?(Hash) && var_def[:type] == :calculated
|
|
65
|
+
return var_def[:project]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check in category variables
|
|
69
|
+
@model.categories.each do |cat|
|
|
70
|
+
var = (cat.variables + cat.descendants.flat_map(&:variables)).find { |v| v[:name] == variable_name }
|
|
71
|
+
return var[:project] if var && var[:project]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Generate report data structure
|
|
78
|
+
def generate
|
|
79
|
+
{
|
|
80
|
+
metadata: build_metadata,
|
|
81
|
+
sections: build_sections,
|
|
82
|
+
totals: calculate_totals,
|
|
83
|
+
period: { start: @start_date, end: @end_date },
|
|
84
|
+
currency: @output_currency
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Output to a specific format
|
|
89
|
+
def output(format_class, options = {})
|
|
90
|
+
output_handler = format_class.new(self, options)
|
|
91
|
+
output_handler.generate
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
protected
|
|
95
|
+
|
|
96
|
+
def build_metadata
|
|
97
|
+
{
|
|
98
|
+
report_type: self.class.name.split('::').last,
|
|
99
|
+
generated_at: Time.now,
|
|
100
|
+
currency: @output_currency,
|
|
101
|
+
filters: @filters
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_sections
|
|
106
|
+
raise NotImplementedError, "Subclasses must implement build_sections"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def calculate_totals
|
|
110
|
+
raise NotImplementedError, "Subclasses must implement calculate_totals"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def calculate_value(variable_name, date = @end_date, period_type: :annual)
|
|
114
|
+
@model.variable_value(variable_name,
|
|
115
|
+
date: date,
|
|
116
|
+
period_type: period_type
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Get period type for this report
|
|
121
|
+
def period_type
|
|
122
|
+
@model.determine_period_type(@start_date, @end_date)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_money(value)
|
|
126
|
+
return 0 unless value
|
|
127
|
+
|
|
128
|
+
if value.is_a?(Money)
|
|
129
|
+
value
|
|
130
|
+
else
|
|
131
|
+
Money.new((value * 100).to_i, @output_currency)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def humanize_name(name)
|
|
136
|
+
name.to_s.split('_').map(&:capitalize).join(' ')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Generate dates for a period range
|
|
140
|
+
def generate_period_dates(start_date, end_date, frequency)
|
|
141
|
+
dates = []
|
|
142
|
+
current = parse_date(start_date)
|
|
143
|
+
end_dt = parse_date(end_date)
|
|
144
|
+
|
|
145
|
+
while current <= end_dt
|
|
146
|
+
dates << current
|
|
147
|
+
current = case frequency
|
|
148
|
+
when :daily
|
|
149
|
+
current + 1
|
|
150
|
+
when :weekly
|
|
151
|
+
current + 7
|
|
152
|
+
when :monthly
|
|
153
|
+
current >> 1
|
|
154
|
+
when :quarterly
|
|
155
|
+
current >> 3
|
|
156
|
+
when :annual
|
|
157
|
+
current >> 12
|
|
158
|
+
else
|
|
159
|
+
current >> 1 # Default monthly
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
dates
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse_date(date)
|
|
167
|
+
return date if date.is_a?(Date)
|
|
168
|
+
return nil if date.nil?
|
|
169
|
+
|
|
170
|
+
case date
|
|
171
|
+
when String
|
|
172
|
+
if date =~ /^\d{4}-\d{2}$/ # YYYY-MM format
|
|
173
|
+
Date.parse("#{date}-01")
|
|
174
|
+
else
|
|
175
|
+
Date.parse(date)
|
|
176
|
+
end
|
|
177
|
+
when Time
|
|
178
|
+
date.to_date
|
|
179
|
+
when Integer
|
|
180
|
+
# Assume year
|
|
181
|
+
Date.new(date, 1, 1)
|
|
182
|
+
else
|
|
183
|
+
date
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Get section value from report data
|
|
188
|
+
def extract_section_value(report_data, section_name)
|
|
189
|
+
sections = report_data[:sections] || {}
|
|
190
|
+
section = sections[section_name.to_sym] || sections[section_name.to_s]
|
|
191
|
+
section ? (section[:total] || section[:value] || 0) : nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Count items in a section
|
|
195
|
+
def count_section_items(report_data, section_name)
|
|
196
|
+
sections = report_data[:sections] || {}
|
|
197
|
+
section = sections[section_name.to_sym] || sections[section_name.to_s]
|
|
198
|
+
return 0 unless section
|
|
199
|
+
|
|
200
|
+
items = section[:items] || []
|
|
201
|
+
count = items.length
|
|
202
|
+
|
|
203
|
+
# Count subcategories recursively
|
|
204
|
+
items.each do |item|
|
|
205
|
+
if item[:subcategories]
|
|
206
|
+
count += item[:subcategories].length
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
count
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def matches_filters?(category)
|
|
216
|
+
return true if @filters.empty?
|
|
217
|
+
|
|
218
|
+
# Support filtering by name pattern, parent, project, etc.
|
|
219
|
+
@filters.all? do |key, value|
|
|
220
|
+
case key
|
|
221
|
+
when :name_includes
|
|
222
|
+
category.name.to_s.include?(value.to_s)
|
|
223
|
+
when :parent
|
|
224
|
+
category.parent&.name == value
|
|
225
|
+
when :exclude
|
|
226
|
+
!Array(value).include?(category.name)
|
|
227
|
+
when :only
|
|
228
|
+
Array(value).include?(category.name)
|
|
229
|
+
when :project
|
|
230
|
+
# Project filtering is handled by category_has_relevant_variables?
|
|
231
|
+
true
|
|
232
|
+
else
|
|
233
|
+
true
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FinIt
|
|
4
|
+
module Reports
|
|
5
|
+
class CashFlowStatement < BaseReport
|
|
6
|
+
attr_accessor :method, :display_mode
|
|
7
|
+
|
|
8
|
+
# Include categories that affect cash flow
|
|
9
|
+
def included_category_types
|
|
10
|
+
[:cash_inflow, :cash_outflow, :income, :expense, :asset, :liability]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(model, start_date:, end_date:, output_currency: 'USD', filters: {}, method: :indirect, display_mode: :standard)
|
|
14
|
+
super(model, start_date: start_date, end_date: end_date, output_currency: output_currency, filters: filters)
|
|
15
|
+
@method = method # :direct or :indirect
|
|
16
|
+
@display_mode = display_mode # :standard (3 sections) or :simple (cash in/out)
|
|
17
|
+
@period_type = model.determine_period_type(start_date, end_date)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def generate
|
|
21
|
+
data = super
|
|
22
|
+
data[:method] = @method
|
|
23
|
+
data[:display_mode] = @display_mode
|
|
24
|
+
data
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_sections
|
|
28
|
+
sections = {}
|
|
29
|
+
|
|
30
|
+
case @display_mode
|
|
31
|
+
when :simple
|
|
32
|
+
sections.merge!(build_simple_sections)
|
|
33
|
+
else # :standard
|
|
34
|
+
sections.merge!(build_standard_sections)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sections
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def calculate_totals
|
|
41
|
+
sections = build_sections
|
|
42
|
+
|
|
43
|
+
if @display_mode == :simple
|
|
44
|
+
{
|
|
45
|
+
cash_in: sections[:cash_in][:total] || 0,
|
|
46
|
+
cash_out: sections[:cash_out][:total] || 0,
|
|
47
|
+
operating: 0,
|
|
48
|
+
investing: 0,
|
|
49
|
+
financing: 0,
|
|
50
|
+
net_change_in_cash: sections[:net_change_in_cash][:total] || 0,
|
|
51
|
+
beginning_cash: sections[:beginning_cash][:total] || 0,
|
|
52
|
+
ending_cash: sections[:ending_cash][:total] || 0
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
{
|
|
56
|
+
operating: sections[:operating][:total] || 0,
|
|
57
|
+
investing: sections[:investing][:total] || 0,
|
|
58
|
+
financing: sections[:financing][:total] || 0,
|
|
59
|
+
net_change_in_cash: sections[:net_change_in_cash][:total] || 0,
|
|
60
|
+
beginning_cash: sections[:beginning_cash][:total] || 0,
|
|
61
|
+
ending_cash: sections[:ending_cash][:total] || 0
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Generate monthly reports for a date range
|
|
67
|
+
def generate_monthly(start_date, end_date)
|
|
68
|
+
dates = generate_period_dates(start_date, end_date, :monthly)
|
|
69
|
+
dates.map do |date|
|
|
70
|
+
month_start = Date.new(date.year, date.month, 1)
|
|
71
|
+
month_end = Date.new(date.year, date.month, -1)
|
|
72
|
+
report = self.class.new(
|
|
73
|
+
@model,
|
|
74
|
+
start_date: month_start,
|
|
75
|
+
end_date: month_end,
|
|
76
|
+
output_currency: @output_currency,
|
|
77
|
+
filters: @filters,
|
|
78
|
+
method: @method,
|
|
79
|
+
display_mode: @display_mode
|
|
80
|
+
)
|
|
81
|
+
{
|
|
82
|
+
period: { start: month_start, end: month_end },
|
|
83
|
+
report: report.generate
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get summary statistics for periods
|
|
89
|
+
def period_summary(period_type: :monthly)
|
|
90
|
+
periods = get_periods(frequency: period_type)
|
|
91
|
+
summaries = periods.map do |date|
|
|
92
|
+
period_start = case period_type
|
|
93
|
+
when :monthly
|
|
94
|
+
Date.new(date.year, date.month, 1)
|
|
95
|
+
when :quarterly
|
|
96
|
+
quarter_month = ((date.month - 1) / 3) * 3 + 1
|
|
97
|
+
Date.new(date.year, quarter_month, 1)
|
|
98
|
+
when :annual
|
|
99
|
+
Date.new(date.year, 1, 1)
|
|
100
|
+
else
|
|
101
|
+
date
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
period_end = case period_type
|
|
105
|
+
when :monthly
|
|
106
|
+
Date.new(date.year, date.month, -1)
|
|
107
|
+
when :quarterly
|
|
108
|
+
quarter_month = ((date.month - 1) / 3) * 3 + 3
|
|
109
|
+
Date.new(date.year, quarter_month, -1)
|
|
110
|
+
when :annual
|
|
111
|
+
Date.new(date.year, 12, 31)
|
|
112
|
+
else
|
|
113
|
+
date
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
report = self.class.new(
|
|
117
|
+
@model,
|
|
118
|
+
start_date: period_start,
|
|
119
|
+
end_date: period_end,
|
|
120
|
+
output_currency: @output_currency,
|
|
121
|
+
filters: @filters,
|
|
122
|
+
method: @method,
|
|
123
|
+
display_mode: @display_mode
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
report_data = report.generate
|
|
127
|
+
totals = report_data[:totals] || {}
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
period: { start: period_start, end: period_end },
|
|
131
|
+
report: report_data,
|
|
132
|
+
operating: totals[:operating] || 0,
|
|
133
|
+
investing: totals[:investing] || 0,
|
|
134
|
+
financing: totals[:financing] || 0,
|
|
135
|
+
net_change_in_cash: totals[:net_change_in_cash] || 0
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
frequency: period_type,
|
|
141
|
+
periods: summaries,
|
|
142
|
+
summary: {
|
|
143
|
+
total_operating: summaries.sum { |s| s[:operating] || 0 },
|
|
144
|
+
total_investing: summaries.sum { |s| s[:investing] || 0 },
|
|
145
|
+
total_financing: summaries.sum { |s| s[:financing] || 0 },
|
|
146
|
+
total_net_change: summaries.sum { |s| s[:net_change_in_cash] || 0 },
|
|
147
|
+
period_count: summaries.length
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def get_periods(frequency: :monthly)
|
|
153
|
+
generate_period_dates(@start_date, @end_date, frequency)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def build_standard_sections
|
|
159
|
+
sections = {}
|
|
160
|
+
|
|
161
|
+
# OPERATING ACTIVITIES
|
|
162
|
+
sections[:operating] = build_operating_section
|
|
163
|
+
|
|
164
|
+
# INVESTING ACTIVITIES
|
|
165
|
+
sections[:investing] = build_investing_section
|
|
166
|
+
|
|
167
|
+
# FINANCING ACTIVITIES
|
|
168
|
+
sections[:financing] = build_financing_section
|
|
169
|
+
|
|
170
|
+
# Calculate totals
|
|
171
|
+
operating_total = sections[:operating][:total] || 0
|
|
172
|
+
investing_total = sections[:investing][:total] || 0
|
|
173
|
+
financing_total = sections[:financing][:total] || 0
|
|
174
|
+
|
|
175
|
+
net_change = operating_total + investing_total + financing_total
|
|
176
|
+
|
|
177
|
+
# NET CHANGE IN CASH
|
|
178
|
+
sections[:net_change_in_cash] = {
|
|
179
|
+
label: "Net Change in Cash",
|
|
180
|
+
formula: "operating + investing + financing",
|
|
181
|
+
value: net_change,
|
|
182
|
+
total: net_change
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# BEGINNING CASH BALANCE
|
|
186
|
+
beginning_cash = calculate_beginning_cash
|
|
187
|
+
sections[:beginning_cash] = {
|
|
188
|
+
label: "Beginning Cash Balance",
|
|
189
|
+
value: beginning_cash,
|
|
190
|
+
total: beginning_cash
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# ENDING CASH BALANCE
|
|
194
|
+
ending_cash = beginning_cash + net_change
|
|
195
|
+
sections[:ending_cash] = {
|
|
196
|
+
label: "Ending Cash Balance",
|
|
197
|
+
formula: "beginning_cash + net_change_in_cash",
|
|
198
|
+
value: ending_cash,
|
|
199
|
+
total: ending_cash
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
sections
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def build_simple_sections
|
|
206
|
+
sections = {}
|
|
207
|
+
|
|
208
|
+
# Simple mode: just cash in and cash out
|
|
209
|
+
cash_in = calculate_total_cash_in
|
|
210
|
+
cash_out = calculate_total_cash_out
|
|
211
|
+
|
|
212
|
+
sections[:cash_in] = {
|
|
213
|
+
name: "Cash Inflows",
|
|
214
|
+
items: build_cash_in_items,
|
|
215
|
+
total: cash_in,
|
|
216
|
+
formula: "total of all cash inflows"
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
sections[:cash_out] = {
|
|
220
|
+
name: "Cash Outflows",
|
|
221
|
+
items: build_cash_out_items,
|
|
222
|
+
total: cash_out,
|
|
223
|
+
formula: "total of all cash outflows"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
net_change = cash_in - cash_out
|
|
227
|
+
|
|
228
|
+
sections[:net_change_in_cash] = {
|
|
229
|
+
label: "Net Change in Cash",
|
|
230
|
+
formula: "cash_in - cash_out",
|
|
231
|
+
value: net_change,
|
|
232
|
+
total: net_change
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
beginning_cash = calculate_beginning_cash
|
|
236
|
+
sections[:beginning_cash] = {
|
|
237
|
+
label: "Beginning Cash Balance",
|
|
238
|
+
value: beginning_cash,
|
|
239
|
+
total: beginning_cash
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
ending_cash = beginning_cash + net_change
|
|
243
|
+
sections[:ending_cash] = {
|
|
244
|
+
label: "Ending Cash Balance",
|
|
245
|
+
formula: "beginning_cash + net_change_in_cash",
|
|
246
|
+
value: ending_cash,
|
|
247
|
+
total: ending_cash
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
sections
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def build_operating_section
|
|
254
|
+
items = []
|
|
255
|
+
total = 0
|
|
256
|
+
|
|
257
|
+
case @method
|
|
258
|
+
when :indirect
|
|
259
|
+
# Start with net income
|
|
260
|
+
net_income = @model.period_net_income(@start_date, @end_date,
|
|
261
|
+
output_currency: @output_currency, filters: @filters)
|
|
262
|
+
|
|
263
|
+
items << {
|
|
264
|
+
name: :net_income,
|
|
265
|
+
display_name: "Net Income",
|
|
266
|
+
value: net_income,
|
|
267
|
+
indent: 0
|
|
268
|
+
}
|
|
269
|
+
total += net_income
|
|
270
|
+
|
|
271
|
+
# Add back non-cash expenses (depreciation, amortization)
|
|
272
|
+
# For now, we assume all income/expenses are cash-based
|
|
273
|
+
# Future: detect and add back depreciation accounts
|
|
274
|
+
|
|
275
|
+
# Working capital changes would go here
|
|
276
|
+
# For the concert model, all transactions flow through checking
|
|
277
|
+
# so net income = operating cash flow
|
|
278
|
+
|
|
279
|
+
when :direct
|
|
280
|
+
# Direct method: list actual cash receipts and payments
|
|
281
|
+
# Use cash_inflow and cash_outflow categories
|
|
282
|
+
cash_inflow_categories = @model.categories.select { |c| c.type == :cash_inflow }
|
|
283
|
+
cash_outflow_categories = @model.categories.select { |c| c.type == :cash_outflow }
|
|
284
|
+
|
|
285
|
+
# Add cash inflows
|
|
286
|
+
cash_inflow_categories.each do |category|
|
|
287
|
+
item = build_category_item(category)
|
|
288
|
+
next unless item
|
|
289
|
+
items << item
|
|
290
|
+
total += item[:value] || 0
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Add cash outflows (as negative)
|
|
294
|
+
cash_outflow_categories.each do |category|
|
|
295
|
+
item = build_category_item(category)
|
|
296
|
+
next unless item
|
|
297
|
+
item[:value] = -(item[:value] || 0).abs
|
|
298
|
+
items << item
|
|
299
|
+
total += item[:value]
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
{
|
|
304
|
+
name: "Operating Activities",
|
|
305
|
+
items: items,
|
|
306
|
+
total: total,
|
|
307
|
+
formula: @method == :indirect ? "net_income + adjustments" : "cash_receipts - cash_payments"
|
|
308
|
+
}
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def build_investing_section
|
|
312
|
+
items = []
|
|
313
|
+
total = 0
|
|
314
|
+
|
|
315
|
+
# Look for fixed asset accounts and calculate changes
|
|
316
|
+
# For the concert model, there are no investing activities
|
|
317
|
+
# Future: detect asset purchases/sales
|
|
318
|
+
|
|
319
|
+
{
|
|
320
|
+
name: "Investing Activities",
|
|
321
|
+
items: items,
|
|
322
|
+
total: total,
|
|
323
|
+
formula: "asset_sales - asset_purchases"
|
|
324
|
+
}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def build_financing_section
|
|
328
|
+
items = []
|
|
329
|
+
total = 0
|
|
330
|
+
|
|
331
|
+
# Look for liability/equity changes
|
|
332
|
+
# For the concert model, there are no financing activities
|
|
333
|
+
# Future: detect debt/equity transactions
|
|
334
|
+
|
|
335
|
+
{
|
|
336
|
+
name: "Financing Activities",
|
|
337
|
+
items: items,
|
|
338
|
+
total: total,
|
|
339
|
+
formula: "debt_proceeds + equity_contributions - debt_payments - dividends"
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def calculate_beginning_cash
|
|
344
|
+
# Get cash balance at start of period
|
|
345
|
+
cash_accounts = @model.accounts.values.select { |acc|
|
|
346
|
+
acc.type == :asset && is_cash_account?(acc)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
# Calculate balance at start_date - 1 day (end of previous period)
|
|
350
|
+
previous_date = @start_date - 1
|
|
351
|
+
|
|
352
|
+
# If previous date is before model start, use opening balance
|
|
353
|
+
if previous_date < @model.start_date
|
|
354
|
+
cash_accounts.sum { |account| account.opening_balance.to_f }
|
|
355
|
+
else
|
|
356
|
+
cash_accounts.sum do |account|
|
|
357
|
+
@model.account_balance(account.name, as_of_date: previous_date)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def calculate_total_cash_in
|
|
363
|
+
# Sum all positive flows to cash accounts
|
|
364
|
+
cash_accounts = @model.accounts.values.select { |acc|
|
|
365
|
+
acc.type == :asset && is_cash_account?(acc)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
total = 0
|
|
369
|
+
cash_accounts.each do |account|
|
|
370
|
+
flow = @model.account_period_flow(account.name, @start_date, @end_date)
|
|
371
|
+
total += flow if flow > 0
|
|
372
|
+
end
|
|
373
|
+
total
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def calculate_total_cash_out
|
|
377
|
+
# Sum all negative flows from cash accounts (as positive number)
|
|
378
|
+
cash_accounts = @model.accounts.values.select { |acc|
|
|
379
|
+
acc.type == :asset && is_cash_account?(acc)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
total = 0
|
|
383
|
+
cash_accounts.each do |account|
|
|
384
|
+
flow = @model.account_period_flow(account.name, @start_date, @end_date)
|
|
385
|
+
total += flow.abs if flow < 0
|
|
386
|
+
end
|
|
387
|
+
total
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def build_cash_in_items
|
|
391
|
+
items = []
|
|
392
|
+
|
|
393
|
+
# Group cash inflows by source (income categories)
|
|
394
|
+
income_categories = @model.categories.select { |c| c.type == :income }
|
|
395
|
+
|
|
396
|
+
income_categories.each do |category|
|
|
397
|
+
value = @model.category_total_for_period(
|
|
398
|
+
category,
|
|
399
|
+
@start_date,
|
|
400
|
+
@end_date,
|
|
401
|
+
period_type: @period_type,
|
|
402
|
+
output_currency: @output_currency,
|
|
403
|
+
filters: @filters
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
next if value == 0
|
|
407
|
+
|
|
408
|
+
items << {
|
|
409
|
+
name: category.name,
|
|
410
|
+
display_name: category.description || humanize_name(category.name),
|
|
411
|
+
value: value,
|
|
412
|
+
indent: 0
|
|
413
|
+
}
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
items
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def build_cash_out_items
|
|
420
|
+
items = []
|
|
421
|
+
|
|
422
|
+
# Group cash outflows by category (expense categories)
|
|
423
|
+
expense_categories = @model.categories.select { |c| c.type == :expense }
|
|
424
|
+
|
|
425
|
+
expense_categories.each do |category|
|
|
426
|
+
value = @model.category_total_for_period(
|
|
427
|
+
category,
|
|
428
|
+
@start_date,
|
|
429
|
+
@end_date,
|
|
430
|
+
period_type: @period_type,
|
|
431
|
+
output_currency: @output_currency,
|
|
432
|
+
filters: @filters
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Expense values are typically negative in account flows
|
|
436
|
+
value = value.abs if value.is_a?(Numeric)
|
|
437
|
+
|
|
438
|
+
next if value == 0
|
|
439
|
+
|
|
440
|
+
items << {
|
|
441
|
+
name: category.name,
|
|
442
|
+
display_name: category.description || humanize_name(category.name),
|
|
443
|
+
value: value,
|
|
444
|
+
indent: 0
|
|
445
|
+
}
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
items
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def build_category_item(category)
|
|
452
|
+
value = @model.category_total_for_period(
|
|
453
|
+
category,
|
|
454
|
+
@start_date,
|
|
455
|
+
@end_date,
|
|
456
|
+
period_type: @period_type,
|
|
457
|
+
output_currency: @output_currency,
|
|
458
|
+
filters: @filters
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return nil if value == 0
|
|
462
|
+
|
|
463
|
+
{
|
|
464
|
+
name: category.name,
|
|
465
|
+
display_name: category.description || humanize_name(category.name),
|
|
466
|
+
value: value,
|
|
467
|
+
indent: 0
|
|
468
|
+
}
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def is_cash_account?(account)
|
|
472
|
+
# Determine if account is a cash account
|
|
473
|
+
# Check by name patterns
|
|
474
|
+
name = account.name.to_s.downcase
|
|
475
|
+
cash_patterns = ['cash', 'checking', 'savings', 'bank', 'money_market']
|
|
476
|
+
cash_patterns.any? { |pattern| name.include?(pattern) }
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|