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,638 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_report'
|
|
4
|
+
|
|
5
|
+
module FinIt
|
|
6
|
+
module Reports
|
|
7
|
+
# Error raised when balance sheet doesn't balance
|
|
8
|
+
class BalanceSheetError < StandardError; end
|
|
9
|
+
|
|
10
|
+
class BalanceSheet < BaseReport
|
|
11
|
+
# Include asset, liability, and equity categories
|
|
12
|
+
def included_category_types
|
|
13
|
+
[:asset, :liability, :equity]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def generate
|
|
17
|
+
data = super
|
|
18
|
+
data[:statement_comments] = statement_comments
|
|
19
|
+
|
|
20
|
+
# Validate balance sheet equation: Assets = Liabilities + Equity
|
|
21
|
+
validate_balance_sheet_equation(data)
|
|
22
|
+
|
|
23
|
+
data
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_sections
|
|
27
|
+
sections = {}
|
|
28
|
+
|
|
29
|
+
# Assets section
|
|
30
|
+
asset_categories = relevant_categories.select { |c| c.type == :asset }
|
|
31
|
+
sections[:assets] = build_asset_section(asset_categories)
|
|
32
|
+
|
|
33
|
+
# Liabilities section
|
|
34
|
+
liability_categories = relevant_categories.select { |c| c.type == :liability }
|
|
35
|
+
sections[:liabilities] = build_liability_section(liability_categories)
|
|
36
|
+
|
|
37
|
+
# Equity section (only actual equity accounts/categories, not net income)
|
|
38
|
+
equity_categories = relevant_categories.select { |c| c.type == :equity }
|
|
39
|
+
sections[:equity] = build_category_section(equity_categories, "Equity")
|
|
40
|
+
sections[:equity][:items] ||= []
|
|
41
|
+
|
|
42
|
+
# If no equity categories but equity accounts exist, show them directly
|
|
43
|
+
# For equity, use opening balance (not dynamic balance) because:
|
|
44
|
+
# 1. Equity opening balance is already calculated as Assets - Liabilities
|
|
45
|
+
# 2. Opening balance transactions would double-count otherwise
|
|
46
|
+
if equity_categories.empty?
|
|
47
|
+
# Calculate total equity from opening balances (already balanced: Assets - Liabilities)
|
|
48
|
+
opening_equity_balance = calculate_opening_equity_balance
|
|
49
|
+
|
|
50
|
+
if opening_equity_balance != 0
|
|
51
|
+
equity_item = {
|
|
52
|
+
name: :equity,
|
|
53
|
+
display_name: "Owner's Equity",
|
|
54
|
+
value: opening_equity_balance,
|
|
55
|
+
indent: 0,
|
|
56
|
+
variables: [],
|
|
57
|
+
description: "Total equity (Assets - Liabilities)",
|
|
58
|
+
original_name: :equity
|
|
59
|
+
}
|
|
60
|
+
sections[:equity][:items] << equity_item
|
|
61
|
+
sections[:equity][:total] = opening_equity_balance
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Calculate accumulated net income from model start date to end date
|
|
66
|
+
# This represents cumulative net income from when the model began
|
|
67
|
+
# We use model start date (not year start) because:
|
|
68
|
+
# 1. Asset balances are cumulative from model start
|
|
69
|
+
# 2. Opening equity is set at model start
|
|
70
|
+
# 3. This ensures Assets = Liabilities + Opening Equity + Accumulated Net Income
|
|
71
|
+
accumulation_start = @model.start_date
|
|
72
|
+
|
|
73
|
+
accumulated_net_income = @model.period_net_income(accumulation_start, @end_date,
|
|
74
|
+
output_currency: @output_currency, filters: @filters)
|
|
75
|
+
|
|
76
|
+
# Create separate section for accumulated net income
|
|
77
|
+
sections[:accumulated_net_income] = {
|
|
78
|
+
name: "Accumulated Net Income",
|
|
79
|
+
items: [{
|
|
80
|
+
name: :accumulated_net_income,
|
|
81
|
+
display_name: "Accumulated Net Income (Period)",
|
|
82
|
+
value: accumulated_net_income,
|
|
83
|
+
indent: 0,
|
|
84
|
+
variables: [],
|
|
85
|
+
description: "Cumulative net income from model start (Income - Expenses)",
|
|
86
|
+
original_name: :accumulated_net_income
|
|
87
|
+
}],
|
|
88
|
+
total: accumulated_net_income,
|
|
89
|
+
formula: "income - expenses"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Also store period net income for validation (just this period, not accumulated)
|
|
93
|
+
period_net_income = @model.period_net_income(@start_date, @end_date,
|
|
94
|
+
output_currency: @output_currency, filters: @filters)
|
|
95
|
+
|
|
96
|
+
# Calculate totals
|
|
97
|
+
total_assets = sections[:assets][:total] || 0
|
|
98
|
+
total_liabilities = sections[:liabilities][:total] || 0
|
|
99
|
+
|
|
100
|
+
sections[:total_assets] = {
|
|
101
|
+
label: "Total Assets",
|
|
102
|
+
formula: "current_assets + fixed_assets",
|
|
103
|
+
value: total_assets,
|
|
104
|
+
total: total_assets
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sections[:total_liabilities] = {
|
|
108
|
+
label: "Total Liabilities",
|
|
109
|
+
formula: "current_liabilities + long_term_liabilities",
|
|
110
|
+
value: total_liabilities,
|
|
111
|
+
total: total_liabilities
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Calculate equity total (equity accounts only, not net income)
|
|
115
|
+
equity_total = sections[:equity][:total] || 0
|
|
116
|
+
accumulated_net_income_total = sections[:accumulated_net_income][:total] || 0
|
|
117
|
+
|
|
118
|
+
# The balance sheet equation: Assets = Liabilities + Equity + Accumulated Net Income
|
|
119
|
+
# This should always balance because:
|
|
120
|
+
# 1. Opening equity = Opening Assets - Opening Liabilities
|
|
121
|
+
# 2. Accumulated net income = Income transactions - Expense transactions
|
|
122
|
+
# 3. Asset account balances include opening balances + income credits - expense debits
|
|
123
|
+
sections[:equity][:total] = equity_total
|
|
124
|
+
|
|
125
|
+
total_equity_and_net_income = equity_total + accumulated_net_income_total
|
|
126
|
+
sections[:total_liabilities_and_equity] = {
|
|
127
|
+
label: "Total Liabilities and Equity",
|
|
128
|
+
formula: "total_liabilities + total_equity + accumulated_net_income",
|
|
129
|
+
value: total_liabilities + total_equity_and_net_income,
|
|
130
|
+
total: total_liabilities + total_equity_and_net_income
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Store period net income for validation and reporting (just this period, not accumulated)
|
|
134
|
+
sections[:period_net_income] = {
|
|
135
|
+
label: "Period Net Income",
|
|
136
|
+
formula: "income - expenses",
|
|
137
|
+
value: period_net_income,
|
|
138
|
+
total: period_net_income
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
sections
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def calculate_totals
|
|
145
|
+
sections = build_sections
|
|
146
|
+
{
|
|
147
|
+
assets: sections[:total_assets][:total] || 0,
|
|
148
|
+
current_assets: sections[:assets][:current_assets] || 0,
|
|
149
|
+
fixed_assets: sections[:assets][:fixed_assets] || 0,
|
|
150
|
+
liabilities: sections[:total_liabilities][:total] || 0,
|
|
151
|
+
current_liabilities: sections[:liabilities][:current_liabilities] || 0,
|
|
152
|
+
long_term_liabilities: sections[:liabilities][:long_term_liabilities] || 0,
|
|
153
|
+
equity: sections[:equity][:total] || 0,
|
|
154
|
+
total_liabilities_and_equity: sections[:total_liabilities_and_equity][:total] || 0
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def statement_comments
|
|
159
|
+
{
|
|
160
|
+
# Override this method in specific reports or via configuration
|
|
161
|
+
# to add explanatory comments for specific categories
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def validate_balance_sheet_equation(data)
|
|
166
|
+
totals = data[:totals] || {}
|
|
167
|
+
total_assets = totals[:assets] || 0
|
|
168
|
+
total_liabilities_and_equity = totals[:total_liabilities_and_equity] || 0
|
|
169
|
+
|
|
170
|
+
# Convert to floats for comparison
|
|
171
|
+
assets_f = total_assets.is_a?(Money) ? total_assets.to_f : total_assets.to_f
|
|
172
|
+
liabilities_equity_f = total_liabilities_and_equity.is_a?(Money) ? total_liabilities_and_equity.to_f : total_liabilities_and_equity.to_f
|
|
173
|
+
|
|
174
|
+
# Allow small rounding differences (0.01)
|
|
175
|
+
# The balance sheet equation: Assets = Liabilities + Equity
|
|
176
|
+
# Where Equity includes opening equity + period net income (Income - Expenses)
|
|
177
|
+
# Since we recalculate equity as Assets - Liabilities, this should always balance
|
|
178
|
+
difference = (assets_f - liabilities_equity_f).abs
|
|
179
|
+
|
|
180
|
+
unless difference <= 0.01
|
|
181
|
+
period_net_income = calculate_period_net_income
|
|
182
|
+
net_income_f = period_net_income.is_a?(Money) ? period_net_income.to_f : period_net_income.to_f
|
|
183
|
+
raise BalanceSheetError.new(
|
|
184
|
+
"Balance sheet does not balance: Assets (#{assets_f}) != Liabilities + Equity (#{liabilities_equity_f}). " \
|
|
185
|
+
"Difference: #{difference}. Period Net Income: #{net_income_f}"
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Generate monthly balance sheets for a date range
|
|
191
|
+
def generate_monthly(start_date, end_date)
|
|
192
|
+
dates = generate_period_dates(start_date, end_date, :monthly)
|
|
193
|
+
dates.map do |date|
|
|
194
|
+
month_start = Date.new(date.year, date.month, 1)
|
|
195
|
+
month_end = Date.new(date.year, date.month, -1)
|
|
196
|
+
report = self.class.new(
|
|
197
|
+
@model,
|
|
198
|
+
start_date: month_start,
|
|
199
|
+
end_date: month_end,
|
|
200
|
+
output_currency: @output_currency,
|
|
201
|
+
filters: @filters
|
|
202
|
+
)
|
|
203
|
+
{
|
|
204
|
+
period: { start: month_start, end: month_end },
|
|
205
|
+
report: report.generate
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Generate yearly balance sheet for a specific year
|
|
211
|
+
def generate_yearly(year)
|
|
212
|
+
year_start = Date.new(year, 1, 1)
|
|
213
|
+
year_end = Date.new(year, 12, 31)
|
|
214
|
+
report = self.class.new(
|
|
215
|
+
@model,
|
|
216
|
+
start_date: year_start,
|
|
217
|
+
end_date: year_end,
|
|
218
|
+
output_currency: @output_currency,
|
|
219
|
+
filters: @filters
|
|
220
|
+
)
|
|
221
|
+
{
|
|
222
|
+
period: { start: year_start, end: year_end },
|
|
223
|
+
report: report.generate
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Generate balance sheet for a specific date (snapshot)
|
|
228
|
+
def generate_at(date)
|
|
229
|
+
date = parse_date(date)
|
|
230
|
+
report = self.class.new(
|
|
231
|
+
@model,
|
|
232
|
+
start_date: date,
|
|
233
|
+
end_date: date,
|
|
234
|
+
output_currency: @output_currency,
|
|
235
|
+
filters: @filters
|
|
236
|
+
)
|
|
237
|
+
{
|
|
238
|
+
period: { start: date, end: date },
|
|
239
|
+
report: report.generate
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Get all periods in the report date range
|
|
244
|
+
def get_periods(frequency: :monthly)
|
|
245
|
+
generate_period_dates(@start_date, @end_date, frequency)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Get value of a specific section at a date
|
|
249
|
+
def section_value(section_name, date: nil)
|
|
250
|
+
date ||= @end_date
|
|
251
|
+
report = self.class.new(
|
|
252
|
+
@model,
|
|
253
|
+
start_date: date,
|
|
254
|
+
end_date: date,
|
|
255
|
+
output_currency: @output_currency,
|
|
256
|
+
filters: @filters
|
|
257
|
+
)
|
|
258
|
+
report_data = report.generate
|
|
259
|
+
extract_section_value(report_data, section_name)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Get count of items in a section
|
|
263
|
+
def section_count(section_name)
|
|
264
|
+
report_data = generate
|
|
265
|
+
count_section_items(report_data, section_name)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def build_asset_section(categories)
|
|
271
|
+
items = []
|
|
272
|
+
current_assets_total = 0
|
|
273
|
+
fixed_assets_total = 0
|
|
274
|
+
|
|
275
|
+
# Group by top-level categories
|
|
276
|
+
top_level = categories.select { |c| c.parent.nil? || !relevant_categories.include?(c.parent) }
|
|
277
|
+
|
|
278
|
+
top_level.each do |category|
|
|
279
|
+
item = build_category_item(category)
|
|
280
|
+
next unless item
|
|
281
|
+
items << item
|
|
282
|
+
|
|
283
|
+
# Categorize as current or fixed asset based on category name or metadata
|
|
284
|
+
if is_current_asset?(category)
|
|
285
|
+
current_assets_total += item[:value] || 0
|
|
286
|
+
else
|
|
287
|
+
fixed_assets_total += item[:value] || 0
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
{
|
|
292
|
+
name: "Assets",
|
|
293
|
+
items: items,
|
|
294
|
+
current_assets: current_assets_total,
|
|
295
|
+
fixed_assets: fixed_assets_total,
|
|
296
|
+
total: current_assets_total + fixed_assets_total,
|
|
297
|
+
formula: "current_assets + fixed_assets"
|
|
298
|
+
}
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def build_liability_section(categories)
|
|
302
|
+
items = []
|
|
303
|
+
current_liabilities_total = 0
|
|
304
|
+
long_term_liabilities_total = 0
|
|
305
|
+
|
|
306
|
+
# Group by top-level categories
|
|
307
|
+
top_level = categories.select { |c| c.parent.nil? || !relevant_categories.include?(c.parent) }
|
|
308
|
+
|
|
309
|
+
top_level.each do |category|
|
|
310
|
+
item = build_category_item(category)
|
|
311
|
+
next unless item
|
|
312
|
+
items << item
|
|
313
|
+
|
|
314
|
+
# Categorize as current or long-term liability based on category name or metadata
|
|
315
|
+
if is_current_liability?(category)
|
|
316
|
+
current_liabilities_total += item[:value] || 0
|
|
317
|
+
else
|
|
318
|
+
long_term_liabilities_total += item[:value] || 0
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Always return a section structure, even if empty
|
|
323
|
+
{
|
|
324
|
+
name: "Liabilities",
|
|
325
|
+
items: items,
|
|
326
|
+
current_liabilities: current_liabilities_total,
|
|
327
|
+
long_term_liabilities: long_term_liabilities_total,
|
|
328
|
+
total: current_liabilities_total + long_term_liabilities_total,
|
|
329
|
+
formula: "current_liabilities + long_term_liabilities"
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def build_category_section(categories, section_name)
|
|
334
|
+
items = []
|
|
335
|
+
total = 0
|
|
336
|
+
|
|
337
|
+
# Group by top-level categories
|
|
338
|
+
top_level = categories.select { |c| c.parent.nil? || !relevant_categories.include?(c.parent) }
|
|
339
|
+
|
|
340
|
+
top_level.each do |category|
|
|
341
|
+
item = build_category_item(category)
|
|
342
|
+
next unless item
|
|
343
|
+
items << item
|
|
344
|
+
total += item[:value] || 0
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Always return a section structure, even if empty
|
|
348
|
+
{
|
|
349
|
+
name: section_name,
|
|
350
|
+
items: items,
|
|
351
|
+
total: total,
|
|
352
|
+
formula: items.any? ? items.map { |i| i[:name] }.join(" + ") : "0"
|
|
353
|
+
}
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def build_category_item(category, indent_level = 0, period_type: :annual)
|
|
357
|
+
# Balance sheet items are point-in-time snapshots, not period-based flows
|
|
358
|
+
# Prefer account hierarchy calculation when available
|
|
359
|
+
|
|
360
|
+
# Special handling for equity categories:
|
|
361
|
+
# Equity opening balance is already calculated as Assets - Liabilities
|
|
362
|
+
# Using dynamic balance would double-count due to opening balance transactions
|
|
363
|
+
if category.type == :equity
|
|
364
|
+
equity_balance = calculate_opening_equity_balance
|
|
365
|
+
return nil if equity_balance == 0
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
name: category.name,
|
|
369
|
+
display_name: category.description || humanize_name(category.name),
|
|
370
|
+
value: equity_balance,
|
|
371
|
+
indent: indent_level,
|
|
372
|
+
variables: [],
|
|
373
|
+
description: "Total equity (Assets - Liabilities)",
|
|
374
|
+
original_name: category.name,
|
|
375
|
+
subcategories: []
|
|
376
|
+
}
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Try account-based calculation first (hierarchical accounts)
|
|
380
|
+
category_account = @model.category_account(category)
|
|
381
|
+
if category_account
|
|
382
|
+
# Use account hierarchy to get balance including children
|
|
383
|
+
value = @model.account_balance_with_children(category_account.name, @end_date, output_currency: @output_currency)
|
|
384
|
+
|
|
385
|
+
# Build subcategories from account children
|
|
386
|
+
subcategories = []
|
|
387
|
+
if category_account.children.any?
|
|
388
|
+
category_account.children.each do |child_account|
|
|
389
|
+
child_value = @model.account_balance_with_children(child_account.name, @end_date, output_currency: @output_currency)
|
|
390
|
+
next if child_value == 0
|
|
391
|
+
|
|
392
|
+
subcategories << {
|
|
393
|
+
name: child_account.name,
|
|
394
|
+
display_name: humanize_name(child_account.name),
|
|
395
|
+
value: child_value,
|
|
396
|
+
indent: indent_level + 1,
|
|
397
|
+
variables: [],
|
|
398
|
+
description: nil,
|
|
399
|
+
original_name: child_account.name
|
|
400
|
+
}
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# If category account has no balance/children, fall through to variable-based approach
|
|
405
|
+
# This handles cases where variables reference DIFFERENT accounts (e.g., variable with account: :checking)
|
|
406
|
+
unless value == 0 && subcategories.empty?
|
|
407
|
+
return {
|
|
408
|
+
name: category.name,
|
|
409
|
+
display_name: category.description || humanize_name(category.name),
|
|
410
|
+
value: value,
|
|
411
|
+
indent: indent_level,
|
|
412
|
+
variables: [],
|
|
413
|
+
description: category.description,
|
|
414
|
+
original_name: category.name,
|
|
415
|
+
subcategories: subcategories
|
|
416
|
+
}
|
|
417
|
+
end
|
|
418
|
+
# Fall through to variable-based approach if category account has no balance
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Fallback to variable-based calculation (backward compatibility)
|
|
422
|
+
# Filter variables by project if filter is set
|
|
423
|
+
filtered_variables = filter_variables_by_project(category.variables)
|
|
424
|
+
return nil if filtered_variables.empty? && category.variables.any?
|
|
425
|
+
|
|
426
|
+
# Check if we should show individual variables
|
|
427
|
+
relevant_children = category.children.select { |child|
|
|
428
|
+
included_category_types.include?(child.type) && matches_filters?(child) && category_has_relevant_variables?(child)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
# If category has subcategories, show them; otherwise show individual variables if multiple exist
|
|
432
|
+
if relevant_children.any?
|
|
433
|
+
# Has subcategories - show them
|
|
434
|
+
# Category total should be sum of children category totals (calculated at category level)
|
|
435
|
+
# Build children items first to get their values
|
|
436
|
+
child_items = relevant_children.map do |child|
|
|
437
|
+
build_category_item(child, indent_level + 1, period_type: period_type || :annual)
|
|
438
|
+
end.compact
|
|
439
|
+
|
|
440
|
+
# Calculate total from children items (sum of their values)
|
|
441
|
+
value = child_items.sum { |child_item| child_item[:value] || 0 }
|
|
442
|
+
|
|
443
|
+
display_name = category.description || humanize_name(category.name)
|
|
444
|
+
|
|
445
|
+
item = {
|
|
446
|
+
name: category.name,
|
|
447
|
+
display_name: display_name,
|
|
448
|
+
value: value,
|
|
449
|
+
indent: indent_level,
|
|
450
|
+
variables: filtered_variables.map { |v| v[:name] },
|
|
451
|
+
description: category.description,
|
|
452
|
+
original_name: category.name,
|
|
453
|
+
subcategories: child_items
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
item
|
|
457
|
+
elsif filtered_variables.length > 1
|
|
458
|
+
# Multiple variables, no subcategories - show each variable separately
|
|
459
|
+
subcategories = filtered_variables.map do |var|
|
|
460
|
+
# Balance sheet values are point-in-time snapshots
|
|
461
|
+
var_value = calculate_variable_value_for_balance_sheet(var)
|
|
462
|
+
|
|
463
|
+
{
|
|
464
|
+
name: var[:name],
|
|
465
|
+
display_name: var[:description] || humanize_name(var[:name]),
|
|
466
|
+
value: var_value,
|
|
467
|
+
indent: indent_level + 1,
|
|
468
|
+
variables: [var[:name]],
|
|
469
|
+
description: var[:description],
|
|
470
|
+
original_name: var[:name]
|
|
471
|
+
}
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
total_value = subcategories.sum { |sub| sub[:value] || 0 }
|
|
475
|
+
|
|
476
|
+
{
|
|
477
|
+
name: category.name,
|
|
478
|
+
display_name: category.description || humanize_name(category.name),
|
|
479
|
+
value: total_value,
|
|
480
|
+
indent: indent_level,
|
|
481
|
+
variables: filtered_variables.map { |v| v[:name] },
|
|
482
|
+
description: category.description,
|
|
483
|
+
original_name: category.name,
|
|
484
|
+
subcategories: subcategories
|
|
485
|
+
}
|
|
486
|
+
elsif filtered_variables.length == 1
|
|
487
|
+
# Single variable - show category total with the variable as a subcategory
|
|
488
|
+
# This ensures individual accounts are always visible in the balance sheet
|
|
489
|
+
var = filtered_variables.first
|
|
490
|
+
var_value = calculate_variable_value_for_balance_sheet(var)
|
|
491
|
+
|
|
492
|
+
subcategory = {
|
|
493
|
+
name: var[:name],
|
|
494
|
+
display_name: var[:description] || humanize_name(var[:name]),
|
|
495
|
+
value: var_value,
|
|
496
|
+
indent: indent_level + 1,
|
|
497
|
+
variables: [var[:name]],
|
|
498
|
+
description: var[:description],
|
|
499
|
+
original_name: var[:name]
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
# Category total should be the variable value (since it's the only one)
|
|
503
|
+
value = var_value
|
|
504
|
+
|
|
505
|
+
display_name = category.description || humanize_name(category.name)
|
|
506
|
+
|
|
507
|
+
{
|
|
508
|
+
name: category.name,
|
|
509
|
+
display_name: display_name,
|
|
510
|
+
value: value,
|
|
511
|
+
indent: indent_level,
|
|
512
|
+
variables: filtered_variables.map { |v| v[:name] },
|
|
513
|
+
description: category.description,
|
|
514
|
+
original_name: category.name,
|
|
515
|
+
subcategories: [subcategory]
|
|
516
|
+
}
|
|
517
|
+
else
|
|
518
|
+
# No variables - use model's category_total_via_account if available
|
|
519
|
+
value = @model.category_total_via_account(category, @start_date, @end_date,
|
|
520
|
+
period_type: :annual, output_currency: @output_currency, use_balance: true)
|
|
521
|
+
|
|
522
|
+
return nil if value == 0
|
|
523
|
+
|
|
524
|
+
display_name = category.description || humanize_name(category.name)
|
|
525
|
+
|
|
526
|
+
{
|
|
527
|
+
name: category.name,
|
|
528
|
+
display_name: display_name,
|
|
529
|
+
value: value,
|
|
530
|
+
indent: indent_level,
|
|
531
|
+
variables: [],
|
|
532
|
+
description: category.description,
|
|
533
|
+
original_name: category.name
|
|
534
|
+
}
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def filter_variables_by_project(variables)
|
|
539
|
+
return variables unless @filters[:project]
|
|
540
|
+
|
|
541
|
+
variables.select { |var| variable_matches_project?(var, @filters[:project]) }
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def calculate_category_total(category, variables)
|
|
545
|
+
# Use model's category_total_via_account method (prefers account hierarchy)
|
|
546
|
+
# Balance sheets are point-in-time, so always use annual period type and balance
|
|
547
|
+
@model.category_total_via_account(
|
|
548
|
+
category,
|
|
549
|
+
@start_date,
|
|
550
|
+
@end_date,
|
|
551
|
+
period_type: :annual,
|
|
552
|
+
output_currency: @output_currency,
|
|
553
|
+
filters: @filters,
|
|
554
|
+
use_balance: true
|
|
555
|
+
)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def calculate_variable_value_for_balance_sheet(var)
|
|
559
|
+
# For balance sheet items, prefer actual account balance over declared variable value
|
|
560
|
+
# This gives the real balance at the report date, including all transactions
|
|
561
|
+
account_name = var[:account]
|
|
562
|
+
if account_name && @model.accounts[account_name]
|
|
563
|
+
# Generate transactions to ensure balance is up to date
|
|
564
|
+
@model.generate_transactions(@end_date)
|
|
565
|
+
balance = @model.account_balance(account_name, as_of_date: @end_date)
|
|
566
|
+
|
|
567
|
+
# Convert to output currency if needed
|
|
568
|
+
account = @model.accounts[account_name]
|
|
569
|
+
if account.currency != @output_currency
|
|
570
|
+
balance_money = Money.new((balance * 100).to_i, account.currency)
|
|
571
|
+
balance = balance_money.exchange_to(@output_currency).to_f
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
balance
|
|
575
|
+
else
|
|
576
|
+
# Fallback to model's category_variable_value method
|
|
577
|
+
@model.category_variable_value(
|
|
578
|
+
var,
|
|
579
|
+
@end_date,
|
|
580
|
+
period_type: :annual,
|
|
581
|
+
output_currency: @output_currency,
|
|
582
|
+
category_type: nil
|
|
583
|
+
)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def is_current_asset?(category)
|
|
588
|
+
# Check category name or metadata for current asset indicators
|
|
589
|
+
name_str = category.name.to_s.downcase
|
|
590
|
+
name_str.include?('current') ||
|
|
591
|
+
name_str.include?('cash') ||
|
|
592
|
+
name_str.include?('checking') ||
|
|
593
|
+
name_str.include?('savings') ||
|
|
594
|
+
name_str.include?('receivable') ||
|
|
595
|
+
category.metadata[:asset_type] == :current
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def is_current_liability?(category)
|
|
599
|
+
# Check category name or metadata for current liability indicators
|
|
600
|
+
name_str = category.name.to_s.downcase
|
|
601
|
+
name_str.include?('current') ||
|
|
602
|
+
name_str.include?('payable') ||
|
|
603
|
+
name_str.include?('credit_card') ||
|
|
604
|
+
name_str.include?('short_term') ||
|
|
605
|
+
category.metadata[:liability_type] == :current
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# Calculate opening equity balance
|
|
609
|
+
# This is the sum of all equity account opening balances.
|
|
610
|
+
# The equity account opening balance is already calculated as: Assets - Liabilities
|
|
611
|
+
# So we just need to sum equity account opening balances, not recalculate from assets/liabilities.
|
|
612
|
+
def calculate_opening_equity_balance
|
|
613
|
+
# Sum equity account opening balances only
|
|
614
|
+
# The equity account is auto-created with opening_balance = assets - liabilities
|
|
615
|
+
equity_accounts = @model.accounts.values.select { |acc| acc.type == :equity }
|
|
616
|
+
equity_accounts.sum do |account|
|
|
617
|
+
opening_balance = account.opening_balance.to_f
|
|
618
|
+
# Convert to output currency if needed
|
|
619
|
+
if account.currency != @output_currency
|
|
620
|
+
money = Money.new((opening_balance * 100).to_i, account.currency)
|
|
621
|
+
money = money.exchange_to(@output_currency)
|
|
622
|
+
opening_balance = money.to_f
|
|
623
|
+
end
|
|
624
|
+
opening_balance
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Calculate period net income (Income - Expenses) for the report period
|
|
629
|
+
# Delegates to model's period_net_income method
|
|
630
|
+
def calculate_period_net_income
|
|
631
|
+
@model.period_net_income(@start_date, @end_date,
|
|
632
|
+
output_currency: @output_currency, filters: @filters)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|