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,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'money'
4
+
5
+ module FinIt
6
+ # Represents a value that can change over time with currency support
7
+ class TemporalValue
8
+ attr_reader :variable_name, :periods, :default_currency
9
+
10
+ def initialize(variable_name, default_currency: 'USD')
11
+ @variable_name = variable_name
12
+ @periods = []
13
+ @default_currency = default_currency
14
+
15
+ # Configure Money gem
16
+ Money.locale_backend = nil
17
+ Money.rounding_mode = BigDecimal::ROUND_HALF_UP
18
+ end
19
+
20
+ # Add a time-bounded value with currency support
21
+ # @param value [Numeric, Money] The value
22
+ # @param start_date [Date, nil] When this value starts (nil = beginning of time)
23
+ # @param end_date [Date, nil] When this value ends (nil = forever)
24
+ # @param currency [String, nil] Currency code (nil for non-financial values like drivers)
25
+ def add_period(value, start_date: nil, end_date: nil, currency: nil, metadata: {})
26
+ currency ||= @default_currency unless currency == nil # Allow explicit nil for drivers
27
+
28
+ # Convert to Money object if currency is provided and not already Money
29
+ stored_value = if value.is_a?(Money)
30
+ value
31
+ elsif currency
32
+ # Money gem uses cents internally
33
+ Money.new((value * 100).to_i, currency)
34
+ else
35
+ # Non-financial value (driver) - store as-is
36
+ value
37
+ end
38
+
39
+ @periods << {
40
+ value: stored_value,
41
+ start_date: parse_date(start_date),
42
+ end_date: parse_date(end_date),
43
+ metadata: metadata
44
+ }
45
+
46
+ # Sort periods by start date
47
+ @periods.sort_by! { |p| p[:start_date] || Date.new(1900, 1, 1) }
48
+ end
49
+
50
+ # Get value for a specific date with optional currency conversion
51
+ # @param date [Date] The date to query
52
+ # @param output_currency [String, nil] Currency to convert to
53
+ # @param period_type [Symbol] The period type to calculate for (:annual, :monthly, :daily, :weekly)
54
+ # @return [Money, Numeric, nil] The value at that date
55
+ def value_at(date, output_currency: nil, period_type: :annual)
56
+ date = parse_date(date)
57
+
58
+ period = @periods.find do |p|
59
+ start_ok = p[:start_date].nil? || p[:start_date] <= date
60
+ end_ok = p[:end_date].nil? || date <= p[:end_date]
61
+ start_ok && end_ok
62
+ end
63
+
64
+ return nil unless period
65
+
66
+ value = period[:value]
67
+ frequency = period[:metadata][:frequency] || :annual
68
+ is_driver = period[:metadata][:is_driver] || false
69
+
70
+ # Driver variables are returned as-is (no scaling or currency conversion)
71
+ return value if is_driver
72
+
73
+ # Scale value based on requested period type for financial variables
74
+ # The stored value is annualized, so we need to scale it down for shorter periods
75
+ # For annual frequency, when requesting monthly, only show in payment month
76
+ scaled_value = scale_value_for_period(value, frequency, period_type, period, date)
77
+
78
+ # Convert if different currency requested
79
+ if output_currency && scaled_value.is_a?(Money) && output_currency != scaled_value.currency.iso_code
80
+ scaled_value.exchange_to(output_currency)
81
+ else
82
+ scaled_value
83
+ end
84
+ end
85
+
86
+ # Get all values in a date range
87
+ # @param start_date [Date] Start of range
88
+ # @param end_date [Date] End of range
89
+ # @return [Array<Hash>] Array of {date:, value:} hashes
90
+ def values_in_range(start_date, end_date, frequency: :monthly)
91
+ start_date = parse_date(start_date)
92
+ end_date = parse_date(end_date)
93
+
94
+ dates = generate_date_range(start_date, end_date, frequency)
95
+
96
+ dates.map do |date|
97
+ {
98
+ date: date,
99
+ value: value_at(date),
100
+ period: find_period_info(date)
101
+ }
102
+ end
103
+ end
104
+
105
+ # Check if value changes in a given range
106
+ def changes_in_range?(start_date, end_date)
107
+ start_date = parse_date(start_date)
108
+ end_date = parse_date(end_date)
109
+
110
+ @periods.count do |p|
111
+ (p[:start_date] && p[:start_date].between?(start_date, end_date)) ||
112
+ (p[:end_date] && p[:end_date].between?(start_date, end_date))
113
+ end > 1
114
+ end
115
+
116
+ private
117
+
118
+ def scale_value_for_period(money_value, frequency, period_type, period = nil, date = nil)
119
+ # If not Money (e.g., a driver variable like employee count), return as-is
120
+ return money_value unless money_value.is_a?(Money)
121
+
122
+ # If period_type matches frequency, return as-is (already annualized)
123
+ # Otherwise, scale based on the requested period
124
+ case period_type
125
+ when :annual
126
+ money_value # Already stored as annual
127
+ when :monthly
128
+ # For annual frequency, only show value in the payment month
129
+ # Default: show in the first month of the period's date range
130
+ if frequency == :annual
131
+ if period && period[:start_date] && date
132
+ # Check if this month contains the payment date
133
+ # For annual payments, default to showing in the month of start_date
134
+ payment_month = period[:start_date].month
135
+ payment_year = period[:start_date].year
136
+ if date.month == payment_month && date.year == payment_year
137
+ # This is the payment month - show full annual value
138
+ money_value
139
+ else
140
+ # Not the payment month - show zero
141
+ Money.new(0, money_value.currency)
142
+ end
143
+ else
144
+ # No date info - divide by 12 as fallback
145
+ Money.new((money_value.fractional / 12.0).round, money_value.currency)
146
+ end
147
+ else
148
+ # For non-annual frequencies, divide annual by 12
149
+ Money.new((money_value.fractional / 12.0).round, money_value.currency)
150
+ end
151
+ when :weekly
152
+ # Divide annual by 52
153
+ Money.new((money_value.fractional / 52.0).round, money_value.currency)
154
+ when :daily
155
+ # Divide annual by 365
156
+ Money.new((money_value.fractional / 365.0).round, money_value.currency)
157
+ when :quarterly
158
+ # For quarterly frequency, when requesting quarterly period_type, return quarterly amount (annual / 4)
159
+ # But if frequency is quarterly and we're asking for quarterly, we want the actual quarterly payment amount
160
+ if frequency == :quarterly
161
+ # Variable is quarterly frequency - return quarterly amount (annual / 4)
162
+ Money.new((money_value.fractional / 4.0).round, money_value.currency)
163
+ else
164
+ # Variable is annual frequency but requesting quarterly - return annual value (show full year in quarter)
165
+ money_value
166
+ end
167
+ else
168
+ money_value
169
+ end
170
+ end
171
+
172
+ def parse_date(date)
173
+ return nil if date.nil?
174
+ return date if date.is_a?(Date)
175
+
176
+ case date
177
+ when String
178
+ if date =~ /^\d{4}-\d{2}$/ # YYYY-MM format
179
+ Date.parse("#{date}-01")
180
+ else
181
+ Date.parse(date)
182
+ end
183
+ when Time
184
+ date.to_date
185
+ else
186
+ raise ArgumentError, "Cannot parse date: #{date}"
187
+ end
188
+ end
189
+
190
+ def generate_date_range(start_date, end_date, frequency)
191
+ dates = []
192
+ current = start_date
193
+
194
+ while current <= end_date
195
+ dates << current
196
+
197
+ current = case frequency
198
+ when :daily
199
+ current + 1
200
+ when :weekly
201
+ current + 7
202
+ when :monthly
203
+ current >> 1 # Next month
204
+ when :quarterly
205
+ current >> 3
206
+ when :annual
207
+ current >> 12
208
+ else
209
+ current >> 1 # Default to monthly
210
+ end
211
+ end
212
+
213
+ dates
214
+ end
215
+
216
+ def find_period_info(date)
217
+ period = @periods.find do |p|
218
+ start_ok = p[:start_date].nil? || p[:start_date] <= date
219
+ end_ok = p[:end_date].nil? || date <= p[:end_date]
220
+ start_ok && end_ok
221
+ end
222
+
223
+ return nil unless period
224
+
225
+ {
226
+ start_date: period[:start_date],
227
+ end_date: period[:end_date],
228
+ metadata: period[:metadata]
229
+ }
230
+ end
231
+
232
+ public
233
+
234
+ # Deep clone this temporal value
235
+ def deep_clone
236
+ cloned = TemporalValue.new(@variable_name, default_currency: @default_currency)
237
+ @periods.each do |period|
238
+ cloned_value = if period[:value].is_a?(Money)
239
+ Money.new(period[:value].fractional, period[:value].currency)
240
+ else
241
+ period[:value]
242
+ end
243
+ cloned.instance_variable_get(:@periods) << {
244
+ value: cloned_value,
245
+ start_date: period[:start_date],
246
+ end_date: period[:end_date],
247
+ metadata: period[:metadata].dup
248
+ }
249
+ end
250
+ cloned
251
+ end
252
+
253
+ # Set or replace a period with a new value
254
+ def set_period(value, start_date: nil, end_date: nil)
255
+ start_date = parse_date(start_date)
256
+ end_date = parse_date(end_date)
257
+
258
+ # Find existing period that matches the date range
259
+ existing_idx = @periods.find_index do |p|
260
+ if start_date && end_date
261
+ p[:start_date] == start_date && p[:end_date] == end_date
262
+ elsif start_date
263
+ p[:start_date] == start_date
264
+ elsif end_date
265
+ p[:end_date] == end_date
266
+ else
267
+ p[:start_date].nil? && p[:end_date].nil?
268
+ end
269
+ end
270
+
271
+ if existing_idx
272
+ # Update existing period
273
+ period = @periods[existing_idx]
274
+ period[:value] = convert_value(value, period[:metadata])
275
+ else
276
+ # Add new period - get currency from first existing period or default
277
+ currency = @periods.any? ? @periods.first[:value]&.currency&.iso_code : @default_currency
278
+ metadata = @periods.any? ? @periods.first[:metadata].dup : {}
279
+ add_period(value, start_date: start_date, end_date: end_date,
280
+ currency: metadata[:is_driver] ? nil : currency, metadata: metadata)
281
+ end
282
+ end
283
+
284
+ # Scale all periods by a factor within optional date range
285
+ def scale_periods(factor, start_date: nil, end_date: nil)
286
+ start_date = parse_date(start_date)
287
+ end_date = parse_date(end_date)
288
+
289
+ @periods.each do |period|
290
+ # Check if period overlaps with the date range
291
+ next unless period_in_range?(period, start_date, end_date)
292
+
293
+ if period[:value].is_a?(Money)
294
+ period[:value] = Money.new((period[:value].fractional * factor).round, period[:value].currency)
295
+ elsif period[:value].is_a?(Numeric)
296
+ period[:value] = period[:value] * factor
297
+ end
298
+ end
299
+ end
300
+
301
+ # Adjust all periods by an amount within optional date range
302
+ def adjust_periods(amount, start_date: nil, end_date: nil)
303
+ start_date = parse_date(start_date)
304
+ end_date = parse_date(end_date)
305
+
306
+ @periods.each do |period|
307
+ next unless period_in_range?(period, start_date, end_date)
308
+
309
+ if period[:value].is_a?(Money)
310
+ period[:value] = Money.new(period[:value].fractional + (amount * 100).to_i, period[:value].currency)
311
+ elsif period[:value].is_a?(Numeric)
312
+ period[:value] = period[:value] + amount
313
+ end
314
+ end
315
+ end
316
+
317
+ private
318
+
319
+ def period_in_range?(period, start_date, end_date)
320
+ return true if start_date.nil? && end_date.nil?
321
+
322
+ period_start = period[:start_date]
323
+ period_end = period[:end_date]
324
+
325
+ # Check if periods overlap
326
+ if start_date && end_date && period_start && period_end
327
+ period_end >= start_date && period_start <= end_date
328
+ elsif start_date && period_end
329
+ period_end >= start_date
330
+ elsif end_date && period_start
331
+ period_start <= end_date
332
+ else
333
+ true
334
+ end
335
+ end
336
+
337
+ def convert_value(value, metadata)
338
+ is_driver = metadata[:is_driver]
339
+ if value.is_a?(Money)
340
+ value
341
+ elsif !is_driver && @default_currency
342
+ Money.new((value * 100).to_i, @default_currency)
343
+ else
344
+ value
345
+ end
346
+ end
347
+ end
348
+ end
349
+
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../account'
4
+
5
+ module FinIt
6
+ class TransactionGenerator
7
+ # Handles account determination for double-entry transactions
8
+ module AccountResolver
9
+ def determine_accounts(var_data, category)
10
+ # If explicit debit/credit accounts are specified, use them
11
+ if var_data[:debit_account] && var_data[:credit_account]
12
+ # Resolve account names (handle hierarchical accounts)
13
+ debit_account = resolve_account_name(var_data[:debit_account])
14
+ credit_account = resolve_account_name(var_data[:credit_account])
15
+ return [debit_account, credit_account] if debit_account && credit_account
16
+ end
17
+
18
+ case category.type
19
+ when :income
20
+ # Income: Debit asset account (where money goes), Credit income P&L account
21
+ # With hierarchical accounts:
22
+ # - var_data[:pl_account] is the variable's auto-created income account (for P&L tracking, e.g., :salary)
23
+ # - var_data[:account] is the asset account where income goes (e.g., :checking)
24
+ # - var_data[:credit_account]/[:debit_account] are explicit overrides if specified
25
+ # - If no explicit account specified, use default asset account
26
+ asset_account = resolve_account_name(var_data[:credit_account] || var_data[:debit_account] || var_data[:account]) || find_default_asset_account
27
+ # P&L account for income tracking (auto-created, stored in var_data[:pl_account])
28
+ income_account = resolve_account_name(var_data[:pl_account] || var_data[:account])
29
+ # If variable account not found, fall back to equity (shouldn't happen with auto-creation)
30
+ income_account ||= find_equity_account
31
+ [asset_account, income_account]
32
+
33
+ when :expense
34
+ # Expense: Debit expense P&L account, Credit asset/liability account (where money comes from)
35
+ # With hierarchical accounts:
36
+ # - var_data[:pl_account] is the variable's auto-created expense account (for P&L tracking, e.g., :rent)
37
+ # - var_data[:account] is the asset account where money comes from (e.g., :checking)
38
+ # - var_data[:debit_account]/[:credit_account] are explicit overrides if specified
39
+ # - If no explicit account specified, use default asset account
40
+ # P&L account for expense tracking (auto-created, stored in var_data[:pl_account])
41
+ expense_account = resolve_account_name(var_data[:pl_account] || var_data[:account])
42
+ # If variable account not found, fall back to equity (shouldn't happen with auto-creation)
43
+ expense_account ||= find_equity_account
44
+ asset_account = resolve_account_name(var_data[:debit_account] || var_data[:credit_account] || var_data[:account]) || find_default_asset_account
45
+ [expense_account, asset_account]
46
+
47
+ when :asset, :liability, :equity
48
+ # These are balance sheet accounts - typically don't generate transactions directly
49
+ # But if account is specified, we might need to handle transfers
50
+ account = var_data[:account] || var_data[:debit_account] || var_data[:credit_account]
51
+ return nil unless account
52
+ # For now, return nil - these will be handled differently
53
+ nil
54
+
55
+ else
56
+ nil
57
+ end
58
+ end
59
+
60
+ # Resolve account name, handling hierarchical accounts
61
+ def resolve_account_name(account_ref)
62
+ return nil unless account_ref
63
+
64
+ account_name = account_ref.is_a?(Array) ? account_ref.last : account_ref
65
+ account_name = account_name.to_sym
66
+
67
+ # Check if account exists
68
+ return account_name if @model.accounts.key?(account_name)
69
+
70
+ # Try hierarchical path lookup if it's an array
71
+ if account_ref.is_a?(Array)
72
+ found_account = @model.find_account_by_path(account_ref.map(&:to_sym))
73
+ return found_account.name if found_account
74
+ end
75
+
76
+ # Try to find by full name if it contains dots
77
+ if account_name.to_s.include?('.')
78
+ path_array = account_name.to_s.split('.').map(&:to_sym)
79
+ found_account = @model.find_account_by_path(path_array)
80
+ return found_account.name if found_account
81
+ end
82
+
83
+ account_name
84
+ end
85
+
86
+ def find_default_asset_account
87
+ # Find an asset account with opening balance (actual bank account) first,
88
+ # then fall back to any asset account, then create a default one
89
+ asset_accounts = @model.accounts.values.select { |acc| acc.type == :asset }
90
+
91
+ # Prefer accounts with opening balance (real accounts like checking/savings)
92
+ asset_account = asset_accounts.find { |acc| acc.opening_balance.to_f != 0 }
93
+ # Fall back to any asset account
94
+ asset_account ||= asset_accounts.first
95
+
96
+ unless asset_account
97
+ # Create default asset account
98
+ asset_account = Account.new(
99
+ :default_asset,
100
+ type: :asset,
101
+ currency: @model.config[:default_currency] || 'USD',
102
+ opening_balance: 0
103
+ )
104
+ @model.accounts[:default_asset] = asset_account
105
+ end
106
+
107
+ asset_account.name
108
+ end
109
+
110
+ def find_equity_account
111
+ equity_account = @model.accounts.values.find { |acc| acc.type == :equity }
112
+ equity_account ? equity_account.name : :equity
113
+ end
114
+
115
+ end
116
+ end
117
+ end
118
+
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FinIt
4
+ class TransactionGenerator
5
+ # Handles transaction cache validation and invalidation
6
+ module CacheManagement
7
+ # Check if transaction cache is valid
8
+ def cache_valid?
9
+ current_hashes = @model.calculator.all_variable_hashes
10
+
11
+ # Check if any variable hash has changed
12
+ current_hashes.each do |var_name, hash|
13
+ if @variable_hash_cache[var_name] != hash
14
+ return false
15
+ end
16
+ end
17
+
18
+ # Check if any variable was removed
19
+ @variable_hash_cache.keys.each do |var_name|
20
+ unless current_hashes.key?(var_name)
21
+ return false
22
+ end
23
+ end
24
+
25
+ true
26
+ end
27
+
28
+ def invalidate_cache_if_needed
29
+ unless cache_valid?
30
+ @transactions.clear
31
+ @variable_hash_cache = @model.calculator.all_variable_hashes.dup
32
+ @max_generated_date = nil
33
+ @opening_balance_transactions_generated = false
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module FinIt
6
+ class TransactionGenerator
7
+ # Handles transaction date generation based on frequency and payment schedules
8
+ module DateGeneration
9
+ def generate_transaction_dates(start_date, end_date, frequency, payment_schedule)
10
+ dates = []
11
+ current = start_date
12
+
13
+ if payment_schedule && payment_schedule.respond_to?(:payment_dates)
14
+ # Use payment schedule to determine dates
15
+ dates = payment_schedule.payment_dates(start_date, end_date)
16
+ else
17
+ # Generate dates based on frequency
18
+ while current <= end_date
19
+ dates << current
20
+
21
+ current = case frequency
22
+ when :daily
23
+ current + 1
24
+ when :weekly
25
+ current + 7
26
+ when :monthly
27
+ current >> 1
28
+ when :quarterly
29
+ current >> 3
30
+ when :annual
31
+ current >> 12
32
+ else
33
+ current >> 1 # Default monthly
34
+ end
35
+ end
36
+ end
37
+
38
+ dates
39
+ end
40
+
41
+ def parse_date(date)
42
+ return date if date.is_a?(Date)
43
+ return nil if date.nil?
44
+
45
+ case date
46
+ when String
47
+ Date.parse(date)
48
+ when Time
49
+ date.to_date
50
+ else
51
+ date
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+