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,581 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class DSLTest < Minitest::Test
6
+ def test_model_builder_creates_model
7
+ model = FinIt.define(default_currency: 'USD') do
8
+ config do
9
+ base_year 2024
10
+ start_date 2024
11
+ end
12
+ end
13
+
14
+ assert model, "Model should be created"
15
+ assert_equal 2024, model.config[:base_year], "Config should be set"
16
+ assert_equal Date.new(2024, 1, 1), model.start_date, "Start date should be set"
17
+ end
18
+
19
+ def test_start_date_as_year_number
20
+ model = FinIt.define(default_currency: 'USD') do
21
+ config do
22
+ start_date 2023
23
+ end
24
+ end
25
+
26
+ assert_equal Date.new(2023, 1, 1), model.start_date, "Start date should parse year number"
27
+ end
28
+
29
+ def test_start_date_as_date_string
30
+ model = FinIt.define(default_currency: 'USD') do
31
+ config do
32
+ start_date "2023-06-15"
33
+ end
34
+ end
35
+
36
+ assert_equal Date.new(2023, 6, 15), model.start_date, "Start date should parse date string"
37
+ end
38
+
39
+ def test_equity_account_auto_created
40
+ model = FinIt.define(default_currency: 'USD') do
41
+ config do
42
+ start_date 2024
43
+ end
44
+
45
+ account :checking do
46
+ type :asset
47
+ currency 'USD'
48
+ opening_balance 10_000
49
+ end
50
+ end
51
+
52
+ assert model.accounts.key?(:equity), "Equity account should be auto-created"
53
+ assert_equal :equity, model.accounts[:equity].type, "Equity account type should be correct"
54
+ end
55
+
56
+ def test_account_definition
57
+ model = FinIt.define(default_currency: 'USD') do
58
+ config do
59
+ start_date 2024
60
+ end
61
+
62
+ account :checking do
63
+ type :asset
64
+ currency 'USD'
65
+ opening_balance 10_000
66
+ end
67
+ end
68
+
69
+ assert model.accounts.key?(:checking), "Account should be defined"
70
+ assert_equal :asset, model.accounts[:checking].type, "Account type should be set"
71
+ assert_equal 10_000, model.accounts[:checking].opening_balance, "Opening balance should be set"
72
+ end
73
+
74
+ def test_accounts_exist
75
+ model = FinIt.define(default_currency: 'USD') do
76
+ config do
77
+ start_date 2024
78
+ end
79
+
80
+ account :checking do
81
+ type :asset
82
+ currency 'USD'
83
+ opening_balance 10_000
84
+ end
85
+
86
+ account :savings do
87
+ type :asset
88
+ currency 'USD'
89
+ opening_balance 50_000
90
+ end
91
+ end
92
+
93
+ assert model.accounts.key?(:checking), "Checking account should exist"
94
+ assert model.accounts.key?(:savings), "Savings account should exist"
95
+ end
96
+
97
+ def test_account_opening_balances
98
+ model = FinIt.define(default_currency: 'USD') do
99
+ config do
100
+ start_date 2024
101
+ end
102
+
103
+ account :checking do
104
+ type :asset
105
+ currency 'USD'
106
+ opening_balance 10_000
107
+ end
108
+
109
+ account :savings do
110
+ type :asset
111
+ currency 'USD'
112
+ opening_balance 50_000
113
+ end
114
+ end
115
+
116
+ assert_equal 10_000, model.accounts[:checking].opening_balance, "Checking opening balance"
117
+ assert_equal 50_000, model.accounts[:savings].opening_balance, "Savings opening balance"
118
+ end
119
+
120
+ def test_account_auto_created_for_variables_in_category
121
+ # Accounts are auto-created when inside income/expense categories
122
+ model = FinIt.define(default_currency: 'USD') do
123
+ config do
124
+ start_date 2024
125
+ end
126
+
127
+ account :valid_account do
128
+ type :asset
129
+ currency 'USD'
130
+ end
131
+
132
+ category :test, type: :income do
133
+ # Account is auto-created for variable
134
+ variable :bad_var, currency: 'USD' do
135
+ value 100
136
+ end
137
+ end
138
+ end
139
+
140
+ # Verify account was auto-created
141
+ assert model.accounts.key?(:bad_var), "Account should be auto-created for variable in income category"
142
+ assert_equal :income, model.accounts[:bad_var].type, "Account type should match category type"
143
+ end
144
+
145
+ def test_category_definition
146
+ model = FinIt.define(default_currency: 'USD') do
147
+ config do
148
+ start_date 2024
149
+ end
150
+
151
+ account :checking do
152
+ type :asset
153
+ currency 'USD'
154
+ opening_balance 0
155
+ end
156
+
157
+ category :income, type: :income, description: "Test income" do
158
+ variable :salary, currency: 'USD', frequency: :annual, account: :checking do
159
+ value 100_000
160
+ end
161
+ end
162
+ end
163
+
164
+ income_category = model.categories.find { |c| c.name == :income }
165
+ assert income_category, "Category should be defined"
166
+ assert_equal :income, income_category.type, "Category type should be set"
167
+ assert_equal 1, income_category.variables.length, "Category should have one variable"
168
+ end
169
+
170
+ def test_calculated_variable_auto_creates_accounts_in_income_category
171
+ # Accounts are now auto-created for calculated variables
172
+ model = FinIt.define(default_currency: 'USD') do
173
+ config do
174
+ start_date 2024
175
+ end
176
+
177
+ category :income, type: :income do
178
+ calculated :revenue, formula: "1000" do
179
+ # Account is auto-created
180
+ end
181
+ end
182
+ end
183
+
184
+ # Verify account was auto-created
185
+ assert model.accounts.key?(:revenue), "Account should be auto-created for calculated variable"
186
+ assert_equal :income, model.accounts[:revenue].type, "Account type should match category type"
187
+ end
188
+
189
+ def test_account_within_category_inherits_type
190
+ model = FinIt.define(default_currency: 'USD') do
191
+ config do
192
+ start_date 2024
193
+ end
194
+
195
+ category :assets, type: :asset, description: 'Assets' do
196
+ account :checking do
197
+ currency 'USD'
198
+ opening_balance 10_000
199
+ # type :asset is inherited from category
200
+ end
201
+ end
202
+ end
203
+
204
+ assert model.accounts.key?(:checking), "Account should be defined"
205
+ assert_equal :asset, model.accounts[:checking].type, "Account type should be inherited from category"
206
+
207
+ # Check that variable was automatically created
208
+ asset_category = model.categories.find { |c| c.name == :assets }
209
+ assert asset_category, "Asset category should exist"
210
+ assert_equal 1, asset_category.variables.length, "Category should have one variable"
211
+
212
+ var = asset_category.variables.first
213
+ assert_equal :checking, var[:name], "Variable name should match account name"
214
+ assert_equal :checking, var[:account], "Variable should reference the account"
215
+ end
216
+
217
+ def test_account_within_nested_category
218
+ model = FinIt.define(default_currency: 'USD') do
219
+ config do
220
+ start_date 2024
221
+ end
222
+
223
+ category :assets, type: :asset, description: 'Assets' do
224
+ category :real_estate, description: 'Real Estate' do # inherits type: :asset
225
+ account :homes do
226
+ currency 'USD'
227
+ opening_balance 500_000
228
+ end
229
+ end
230
+ end
231
+ end
232
+
233
+ assert model.accounts.key?(:homes), "Account should be defined"
234
+ assert_equal :asset, model.accounts[:homes].type, "Account type should be inherited"
235
+
236
+ # Check that variable was created in nested category
237
+ asset_category = model.categories.find { |c| c.name == :assets }
238
+ real_estate_category = asset_category.children.find { |c| c.name == :real_estate }
239
+ assert_equal 1, real_estate_category.variables.length, "Nested category should have one variable"
240
+ assert_equal :homes, real_estate_category.variables.first[:name], "Variable should reference account"
241
+ end
242
+
243
+ def test_equity_category_custom_name
244
+ model = FinIt.define(default_currency: 'USD') do
245
+ config do
246
+ start_date 2024
247
+ end
248
+
249
+ account :cash do
250
+ type :asset
251
+ currency 'USD'
252
+ opening_balance 100_000
253
+ end
254
+
255
+ category :networth, type: :equity, description: 'Net Worth' do
256
+ # Equity account will be auto-created
257
+ end
258
+ end
259
+
260
+ # Equity account should be auto-created
261
+ assert model.accounts.key?(:equity), "Equity account should be auto-created"
262
+
263
+ # Net worth category should exist
264
+ networth_category = model.categories.find { |c| c.name == :networth }
265
+ assert networth_category, "Net worth category should exist"
266
+ assert_equal :equity, networth_category.type, "Category type should be equity"
267
+
268
+ # Variable should be added to networth category
269
+ assert networth_category.variables.any?, "Net worth category should have variables"
270
+ end
271
+
272
+ def test_account_outside_category_still_requires_type
273
+ error = assert_raises(ArgumentError) do
274
+ FinIt.define(default_currency: 'USD') do
275
+ config do
276
+ start_date 2024
277
+ end
278
+
279
+ account :checking do
280
+ currency 'USD'
281
+ opening_balance 10_000
282
+ # Missing type - should raise error
283
+ end
284
+ end
285
+ end
286
+
287
+ assert_match(/must specify type/, error.message, "Error should mention missing type")
288
+ end
289
+
290
+ def test_top_level_calculated_value_no_accounts_required
291
+ model = FinIt.define(default_currency: 'USD') do
292
+ config do
293
+ start_date 2024
294
+ end
295
+
296
+ calculated_value :profit_margin, formula: "net_income / revenue"
297
+ end
298
+
299
+ # Should not raise error - top-level calculated values don't need accounts
300
+ assert model, "Model should be created"
301
+ end
302
+
303
+ # === Type Inheritance Tests ===
304
+
305
+ def test_nested_category_inherits_type_from_parent
306
+ model = FinIt.define(default_currency: 'USD') do
307
+ config do
308
+ start_date 2024
309
+ end
310
+
311
+ account :checking do
312
+ type :asset
313
+ currency 'USD'
314
+ opening_balance 10_000
315
+ end
316
+
317
+ category :expenses, type: :expense do
318
+ category :cogs do # No type specified - should inherit :expense
319
+ category :food do # No type - should inherit :expense
320
+ variable :food_cost, account: :checking do
321
+ value 1000
322
+ end
323
+ end
324
+ end
325
+ end
326
+ end
327
+
328
+ expenses_cat = model.categories.find { |c| c.name == :expenses }
329
+ cogs_cat = expenses_cat.children.find { |c| c.name == :cogs }
330
+ food_cat = cogs_cat.children.find { |c| c.name == :food }
331
+
332
+ assert_equal :expense, cogs_cat.type, "COGS should inherit :expense from parent"
333
+ assert_equal :expense, food_cat.type, "Food should inherit :expense from grandparent"
334
+ end
335
+
336
+ def test_nested_category_can_override_type
337
+ model = FinIt.define(default_currency: 'USD') do
338
+ config do
339
+ start_date 2024
340
+ end
341
+
342
+ category :operations, type: :expense do
343
+ category :metrics, type: :metric do # Override type
344
+ variable :conversion_rate do
345
+ value 0.05
346
+ end
347
+ end
348
+ end
349
+ end
350
+
351
+ operations_cat = model.categories.find { |c| c.name == :operations }
352
+ metrics_cat = operations_cat.children.find { |c| c.name == :metrics }
353
+
354
+ assert_equal :expense, operations_cat.type
355
+ assert_equal :metric, metrics_cat.type, "Should use explicit type, not inherit"
356
+ end
357
+
358
+ def test_top_level_category_requires_type
359
+ error = assert_raises(ArgumentError) do
360
+ FinIt.define(default_currency: 'USD') do
361
+ config do
362
+ start_date 2024
363
+ end
364
+
365
+ category :income do # No type - should raise
366
+ variable :revenue do
367
+ value 1000
368
+ end
369
+ end
370
+ end
371
+ end
372
+
373
+ assert_match(/must specify type/, error.message)
374
+ end
375
+
376
+ # === Default Account Inheritance Tests ===
377
+
378
+ def test_default_account_inherited_by_children
379
+ model = FinIt.define(default_currency: 'USD') do
380
+ config do
381
+ start_date 2024
382
+ end
383
+
384
+ account :operating_account do
385
+ type :asset
386
+ currency 'USD'
387
+ opening_balance 50_000
388
+ end
389
+
390
+ category :revenue, type: :income, default_account: :operating_account do
391
+ category :sales do
392
+ calculated :product_sales, formula: "100 * 10"
393
+ end
394
+
395
+ category :services do
396
+ calculated :consulting, formula: "500 * 5"
397
+ end
398
+ end
399
+ end
400
+
401
+ revenue_cat = model.categories.find { |c| c.name == :revenue }
402
+ sales_cat = revenue_cat.children.find { |c| c.name == :sales }
403
+ services_cat = revenue_cat.children.find { |c| c.name == :services }
404
+
405
+ assert_equal :operating_account, revenue_cat.default_account
406
+ assert_equal :operating_account, sales_cat.default_account, "Sales should inherit default_account"
407
+ assert_equal :operating_account, services_cat.default_account, "Services should inherit default_account"
408
+ end
409
+
410
+ def test_child_category_can_override_default_account
411
+ model = FinIt.define(default_currency: 'USD') do
412
+ config do
413
+ start_date 2024
414
+ end
415
+
416
+ account :main_account do
417
+ type :asset
418
+ currency 'USD'
419
+ opening_balance 50_000
420
+ end
421
+
422
+ account :special_account do
423
+ type :asset
424
+ currency 'USD'
425
+ opening_balance 10_000
426
+ end
427
+
428
+ category :income, type: :income, default_account: :main_account do
429
+ category :special, default_account: :special_account do
430
+ calculated :special_income, formula: "1000"
431
+ end
432
+ end
433
+ end
434
+
435
+ income_cat = model.categories.find { |c| c.name == :income }
436
+ special_cat = income_cat.children.find { |c| c.name == :special }
437
+
438
+ assert_equal :main_account, income_cat.default_account
439
+ assert_equal :special_account, special_cat.default_account, "Should use overridden default_account"
440
+ end
441
+
442
+ # === Defaults Hash Tests (frequency, start_date, end_date) ===
443
+
444
+ def test_defaults_hash_inherited_by_children
445
+ model = FinIt.define(default_currency: 'USD') do
446
+ config do
447
+ start_date 2024
448
+ end
449
+
450
+ account :checking do
451
+ type :asset
452
+ currency 'USD'
453
+ opening_balance 10_000
454
+ end
455
+
456
+ category :expenses, type: :expense,
457
+ default_account: :checking,
458
+ defaults: { frequency: :monthly, start_date: "2024-01-01", end_date: "2024-12-31" } do
459
+ category :cogs do
460
+ calculated :food_costs, formula: "1000"
461
+ end
462
+ end
463
+ end
464
+
465
+ expenses_cat = model.categories.find { |c| c.name == :expenses }
466
+ cogs_cat = expenses_cat.children.find { |c| c.name == :cogs }
467
+
468
+ expected_defaults = { frequency: :monthly, start_date: "2024-01-01", end_date: "2024-12-31" }
469
+
470
+ assert_equal expected_defaults, expenses_cat.defaults
471
+ assert_equal expected_defaults, cogs_cat.defaults, "COGS should inherit defaults from parent"
472
+ end
473
+
474
+ def test_child_can_merge_defaults
475
+ model = FinIt.define(default_currency: 'USD') do
476
+ config do
477
+ start_date 2024
478
+ end
479
+
480
+ account :checking do
481
+ type :asset
482
+ currency 'USD'
483
+ end
484
+
485
+ category :expenses, type: :expense,
486
+ default_account: :checking,
487
+ defaults: { frequency: :monthly, start_date: "2024-01-01", end_date: "2024-12-31" } do
488
+ category :special, defaults: { frequency: :weekly } do # Override frequency only
489
+ calculated :special_expense, formula: "500"
490
+ end
491
+ end
492
+ end
493
+
494
+ expenses_cat = model.categories.find { |c| c.name == :expenses }
495
+ special_cat = expenses_cat.children.find { |c| c.name == :special }
496
+
497
+ assert_equal :monthly, expenses_cat.defaults[:frequency]
498
+ assert_equal :weekly, special_cat.defaults[:frequency], "Should override frequency"
499
+ assert_equal "2024-01-01", special_cat.defaults[:start_date], "Should inherit start_date"
500
+ assert_equal "2024-12-31", special_cat.defaults[:end_date], "Should inherit end_date"
501
+ end
502
+
503
+ # === Formula Variable Validation Tests ===
504
+
505
+ def test_undefined_variable_in_formula_raises_error
506
+ error = assert_raises(FinIt::UndefinedVariableError) do
507
+ FinIt.define(default_currency: 'USD') do
508
+ config do
509
+ start_date 2024
510
+ end
511
+
512
+ account :checking do
513
+ type :asset
514
+ currency 'USD'
515
+ end
516
+
517
+ category :income, type: :income do
518
+ calculated :revenue, formula: "undefined_var * 2", account: :checking
519
+ end
520
+ end
521
+ end
522
+
523
+ assert_match(/revenue/, error.message, "Should mention the variable with bad formula")
524
+ assert_match(/undefined_var/, error.message, "Should mention the undefined variable")
525
+ end
526
+
527
+ def test_multiple_undefined_variables_listed_in_error
528
+ error = assert_raises(FinIt::UndefinedVariableError) do
529
+ FinIt.define(default_currency: 'USD') do
530
+ config do
531
+ start_date 2024
532
+ end
533
+
534
+ account :checking do
535
+ type :asset
536
+ currency 'USD'
537
+ end
538
+
539
+ category :income, type: :income do
540
+ calculated :revenue, formula: "var_a + var_b + var_c", account: :checking
541
+ end
542
+ end
543
+ end
544
+
545
+ assert_match(/var_a/, error.message)
546
+ assert_match(/var_b/, error.message)
547
+ assert_match(/var_c/, error.message)
548
+ end
549
+
550
+ def test_defined_variables_pass_validation
551
+ # Should not raise any error
552
+ model = FinIt.define(default_currency: 'USD') do
553
+ config do
554
+ start_date 2024
555
+ end
556
+
557
+ account :checking do
558
+ type :asset
559
+ currency 'USD'
560
+ opening_balance 10_000
561
+ end
562
+
563
+ category :drivers, type: :driver do
564
+ variable :units_sold do
565
+ value 100
566
+ end
567
+
568
+ variable :price_per_unit do
569
+ value 50
570
+ end
571
+ end
572
+
573
+ category :income, type: :income do
574
+ calculated :revenue, formula: "units_sold * price_per_unit", account: :checking
575
+ end
576
+ end
577
+
578
+ assert model, "Model should be created when all variables are defined"
579
+ end
580
+ end
581
+