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