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,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ module Reports
5
+ # A customizable report for creating intro/summary sheets
6
+ # Supports both static content (headers, paragraphs, tables) and
7
+ # dynamic content (model metrics, account balances, scenario comparisons)
8
+ #
9
+ # Usage:
10
+ # # Static content only
11
+ # intro = FinIt::Reports::CustomSheet.build(title: "Overview") do
12
+ # header "Financial Model", level: 1
13
+ # paragraph "This report covers fiscal year 2025."
14
+ # bullet_list ["Scenario A", "Scenario B"]
15
+ # table(headers: ["Metric", "Value"], rows: [["Revenue", "$100k"]])
16
+ # end
17
+ #
18
+ # # With dynamic model data
19
+ # intro = FinIt::Reports::CustomSheet.build(title: "Summary", model: model) do
20
+ # header "Key Metrics", level: 1
21
+ # model_metric :net_income, label: "Net Income"
22
+ # account_balance :checking, label: "Cash Position"
23
+ # metrics_table(metrics: [:net_income, :total_expenses])
24
+ # end
25
+ #
26
+ class CustomSheet
27
+ attr_reader :title, :sections, :model, :output_currency
28
+
29
+ # Alias for compatibility with BaseOutput
30
+ alias_method :currency, :output_currency
31
+
32
+ def initialize(title:, sections: [], model: nil, output_currency: 'USD')
33
+ @title = title
34
+ @sections = sections
35
+ @model = model
36
+ @output_currency = output_currency
37
+ end
38
+
39
+ # DSL builder for creating custom sheets
40
+ def self.build(title:, model: nil, output_currency: 'USD', &block)
41
+ sheet = new(title: title, model: model, output_currency: output_currency)
42
+ sheet.instance_eval(&block) if block_given?
43
+ sheet
44
+ end
45
+
46
+ # --- Static Content DSL ---
47
+
48
+ def header(text, level: 1)
49
+ @sections << { type: :header, text: text, level: level }
50
+ self
51
+ end
52
+
53
+ def paragraph(text)
54
+ @sections << { type: :paragraph, text: text }
55
+ self
56
+ end
57
+
58
+ def spacer(rows: 1)
59
+ @sections << { type: :spacer, rows: rows }
60
+ self
61
+ end
62
+
63
+ def key_value(label, value)
64
+ @sections << { type: :key_value, label: label, value: value }
65
+ self
66
+ end
67
+
68
+ def bullet_list(items)
69
+ @sections << { type: :bullet_list, items: items }
70
+ self
71
+ end
72
+
73
+ def numbered_list(items)
74
+ @sections << { type: :numbered_list, items: items }
75
+ self
76
+ end
77
+
78
+ def table(headers:, rows:, title: nil)
79
+ @sections << { type: :table, headers: headers, rows: rows, title: title }
80
+ self
81
+ end
82
+
83
+ def horizontal_rule
84
+ @sections << { type: :hr }
85
+ self
86
+ end
87
+
88
+ def image(path, alt_text: nil, width: nil, height: nil)
89
+ @sections << { type: :image, path: path, alt_text: alt_text, width: width, height: height }
90
+ self
91
+ end
92
+
93
+ def note(text, style: :info)
94
+ # style can be :info, :warning, :success, :error
95
+ @sections << { type: :note, text: text, style: style }
96
+ self
97
+ end
98
+
99
+ # --- Dynamic Content DSL (requires model) ---
100
+
101
+ def model_metric(variable, label: nil, start_date: nil, end_date: nil, format: :currency)
102
+ require_model!("model_metric")
103
+ @sections << {
104
+ type: :model_metric,
105
+ variable: variable,
106
+ label: label || humanize_name(variable),
107
+ start_date: start_date,
108
+ end_date: end_date,
109
+ format: format
110
+ }
111
+ self
112
+ end
113
+
114
+ def account_balance(account_name, label: nil, as_of: nil, format: :currency)
115
+ require_model!("account_balance")
116
+ @sections << {
117
+ type: :account_balance,
118
+ account: account_name,
119
+ label: label || humanize_name(account_name),
120
+ as_of: as_of,
121
+ format: format
122
+ }
123
+ self
124
+ end
125
+
126
+ def metrics_table(metrics:, title: nil, start_date: nil, end_date: nil)
127
+ require_model!("metrics_table")
128
+ @sections << {
129
+ type: :metrics_table,
130
+ title: title,
131
+ metrics: metrics,
132
+ start_date: start_date,
133
+ end_date: end_date
134
+ }
135
+ self
136
+ end
137
+
138
+ def scenario_comparison_summary(scenarios:, metrics:, start_date: nil, end_date: nil, title: nil)
139
+ require_model!("scenario_comparison_summary")
140
+ @sections << {
141
+ type: :scenario_comparison_summary,
142
+ scenarios: scenarios,
143
+ metrics: metrics,
144
+ start_date: start_date,
145
+ end_date: end_date,
146
+ title: title
147
+ }
148
+ self
149
+ end
150
+
151
+ def period_comparison_summary(periods:, metrics:, title: nil)
152
+ require_model!("period_comparison_summary")
153
+ @sections << {
154
+ type: :period_comparison_summary,
155
+ periods: periods,
156
+ metrics: metrics,
157
+ title: title
158
+ }
159
+ self
160
+ end
161
+
162
+ # --- Report Generation ---
163
+
164
+ def generate
165
+ # Resolve dynamic content if model is present
166
+ resolved_sections = resolve_dynamic_sections
167
+
168
+ {
169
+ report_type: "CustomSheet",
170
+ title: @title,
171
+ sections: resolved_sections,
172
+ currency: @output_currency,
173
+ generated_at: Time.now
174
+ }
175
+ end
176
+
177
+ def output(output_class, **options)
178
+ output_class.new(self, options).generate
179
+ end
180
+
181
+ private
182
+
183
+ def require_model!(method_name)
184
+ raise ArgumentError, "Model required for #{method_name}. Pass model: to CustomSheet.build" unless @model
185
+ end
186
+
187
+ def humanize_name(name)
188
+ name.to_s.split('_').map(&:capitalize).join(' ')
189
+ end
190
+
191
+ def resolve_dynamic_sections
192
+ @sections.map do |section|
193
+ case section[:type]
194
+ when :model_metric
195
+ resolve_model_metric(section)
196
+ when :account_balance
197
+ resolve_account_balance(section)
198
+ when :metrics_table
199
+ resolve_metrics_table(section)
200
+ when :scenario_comparison_summary
201
+ resolve_scenario_comparison_summary(section)
202
+ when :period_comparison_summary
203
+ resolve_period_comparison_summary(section)
204
+ else
205
+ section
206
+ end
207
+ end
208
+ end
209
+
210
+ def resolve_model_metric(section)
211
+ start_date = parse_date(section[:start_date]) if section[:start_date]
212
+ end_date = parse_date(section[:end_date]) if section[:end_date]
213
+
214
+ # Use end_date for calculation, or model's default end
215
+ calc_date = end_date || Date.today
216
+
217
+ # Ensure transactions are generated
218
+ @model.generate_transactions(calc_date)
219
+
220
+ value = calculate_metric(@model, section[:variable], start_date, end_date)
221
+ formatted_value = format_value(value, section[:format])
222
+
223
+ {
224
+ type: :key_value,
225
+ label: section[:label],
226
+ value: formatted_value
227
+ }
228
+ end
229
+
230
+ def resolve_account_balance(section)
231
+ as_of = section[:as_of] ? parse_date(section[:as_of]) : Date.today
232
+
233
+ @model.generate_transactions(as_of)
234
+
235
+ value = @model.account_balance(section[:account], as_of_date: as_of)
236
+ formatted_value = format_value(value, section[:format])
237
+
238
+ {
239
+ type: :key_value,
240
+ label: section[:label],
241
+ value: formatted_value
242
+ }
243
+ end
244
+
245
+ def resolve_metrics_table(section)
246
+ start_date = parse_date(section[:start_date]) if section[:start_date]
247
+ end_date = parse_date(section[:end_date]) if section[:end_date]
248
+ calc_date = end_date || Date.today
249
+
250
+ @model.generate_transactions(calc_date)
251
+
252
+ rows = section[:metrics].map do |metric|
253
+ value = calculate_metric(@model, metric, start_date, end_date)
254
+ [humanize_name(metric), format_value(value, :currency)]
255
+ end
256
+
257
+ {
258
+ type: :table,
259
+ title: section[:title],
260
+ headers: ["Metric", "Value"],
261
+ rows: rows
262
+ }
263
+ end
264
+
265
+ def resolve_scenario_comparison_summary(section)
266
+ start_date = parse_date(section[:start_date]) if section[:start_date]
267
+ end_date = parse_date(section[:end_date]) if section[:end_date]
268
+ calc_date = end_date || Date.today
269
+
270
+ @model.generate_transactions(calc_date)
271
+ section[:scenarios].each_value { |m| m.generate_transactions(calc_date) }
272
+
273
+ headers = ["Metric", "Base"] + section[:scenarios].keys.map { |k| humanize_name(k) }
274
+
275
+ rows = section[:metrics].map do |metric|
276
+ base_value = calculate_metric(@model, metric, start_date, end_date)
277
+ scenario_values = section[:scenarios].values.map do |scenario_model|
278
+ calculate_metric(scenario_model, metric, start_date, end_date)
279
+ end
280
+
281
+ [humanize_name(metric), format_value(base_value, :currency)] +
282
+ scenario_values.map { |v| format_value(v, :currency) }
283
+ end
284
+
285
+ {
286
+ type: :table,
287
+ title: section[:title] || "Scenario Comparison",
288
+ headers: headers,
289
+ rows: rows
290
+ }
291
+ end
292
+
293
+ def resolve_period_comparison_summary(section)
294
+ periods = section[:periods].map do |p|
295
+ {
296
+ name: p[:name],
297
+ start_date: parse_date(p[:start_date]),
298
+ end_date: parse_date(p[:end_date])
299
+ }
300
+ end
301
+
302
+ # Generate transactions for the latest period
303
+ last_date = periods.map { |p| p[:end_date] }.max
304
+ @model.generate_transactions(last_date)
305
+
306
+ headers = ["Metric"] + periods.map { |p| p[:name] }
307
+
308
+ rows = section[:metrics].map do |metric|
309
+ period_values = periods.map do |period|
310
+ calculate_metric(@model, metric, period[:start_date], period[:end_date])
311
+ end
312
+
313
+ [humanize_name(metric)] + period_values.map { |v| format_value(v, :currency) }
314
+ end
315
+
316
+ {
317
+ type: :table,
318
+ title: section[:title] || "Period Comparison",
319
+ headers: headers,
320
+ rows: rows
321
+ }
322
+ end
323
+
324
+ def calculate_metric(model, metric, start_date, end_date)
325
+ case metric
326
+ when :net_income
327
+ model.period_net_income(
328
+ start_date || model.start_date,
329
+ end_date || Date.today,
330
+ output_currency: @output_currency
331
+ )
332
+ when :total_revenue, :total_income
333
+ calculate_total_income(model, start_date, end_date)
334
+ when :total_expenses
335
+ calculate_total_expenses(model, start_date, end_date)
336
+ when :ending_cash
337
+ calculate_ending_cash(model, end_date)
338
+ else
339
+ model.calculator.calculate(
340
+ metric,
341
+ date: end_date || Date.today,
342
+ output_currency: @output_currency
343
+ )
344
+ end
345
+ end
346
+
347
+ def calculate_total_income(model, start_date, end_date)
348
+ income_categories = model.categories.select { |c| c.type == :income }
349
+ income_categories.sum do |category|
350
+ model.category_total_for_period(
351
+ category,
352
+ start_date || model.start_date,
353
+ end_date || Date.today,
354
+ period_type: :annual,
355
+ output_currency: @output_currency
356
+ ).abs
357
+ end
358
+ end
359
+
360
+ def calculate_total_expenses(model, start_date, end_date)
361
+ expense_categories = model.categories.select { |c| c.type == :expense }
362
+ expense_categories.sum do |category|
363
+ model.category_total_for_period(
364
+ category,
365
+ start_date || model.start_date,
366
+ end_date || Date.today,
367
+ period_type: :annual,
368
+ output_currency: @output_currency
369
+ ).abs
370
+ end
371
+ end
372
+
373
+ def calculate_ending_cash(model, end_date)
374
+ cash_accounts = model.accounts.values.select do |acc|
375
+ acc.type == :asset && is_cash_account?(acc)
376
+ end
377
+
378
+ cash_accounts.sum do |account|
379
+ model.account_balance(account.name, as_of_date: end_date || Date.today)
380
+ end
381
+ end
382
+
383
+ def is_cash_account?(account)
384
+ name = account.name.to_s.downcase
385
+ cash_patterns = ['cash', 'checking', 'savings', 'bank', 'money_market']
386
+ cash_patterns.any? { |pattern| name.include?(pattern) }
387
+ end
388
+
389
+ def format_value(value, format)
390
+ case format
391
+ when :currency
392
+ if value.is_a?(Money)
393
+ "$#{format_number(value.to_f)}"
394
+ else
395
+ "$#{format_number(value.to_f)}"
396
+ end
397
+ when :number
398
+ format_number(value.to_f)
399
+ when :percentage
400
+ "#{(value.to_f * 100).round(2)}%"
401
+ else
402
+ value.to_s
403
+ end
404
+ end
405
+
406
+ def format_number(num)
407
+ # Format with thousands separator and 2 decimal places
408
+ parts = ('%.2f' % num).split('.')
409
+ parts[0].gsub!(/(\d)(?=(\d\d\d)+(?!\d))/, '\\1,')
410
+ parts.join('.')
411
+ end
412
+
413
+ def parse_date(date)
414
+ return date if date.is_a?(Date)
415
+ return nil if date.nil?
416
+
417
+ case date
418
+ when String
419
+ if date =~ /^\d{4}$/
420
+ Date.new(date.to_i, 1, 1)
421
+ elsif date =~ /^\d{4}-\d{2}$/
422
+ Date.parse("#{date}-01")
423
+ else
424
+ Date.parse(date)
425
+ end
426
+ when Integer
427
+ Date.new(date, 1, 1)
428
+ when Time
429
+ date.to_date
430
+ else
431
+ raise ArgumentError, "Cannot parse date: #{date}"
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end