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,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
+