teri 0.5.1

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.
@@ -0,0 +1,399 @@
1
+ module Teri
2
+ # Represents a single entry (debit or credit) in a transaction
3
+ class Entry
4
+ attr_reader :account, :amount, :currency, :type
5
+
6
+ # Initialize a new entry
7
+ # @param account [String] The account name
8
+ # @param amount [Float] The amount (positive value)
9
+ # @param currency [String] The currency code
10
+ # @param type [Symbol] Either :debit or :credit
11
+ def initialize(account:, amount:, type:, currency: 'USD')
12
+ @account = account
13
+ @amount = amount.abs # Always store as positive
14
+ @currency = normalize_currency(currency)
15
+ @type = type.to_sym
16
+
17
+ raise ArgumentError, 'Type must be :debit or :credit' unless [:debit, :credit].include?(@type)
18
+ raise ArgumentError, 'Amount must be positive' unless @amount.positive?
19
+ end
20
+
21
+ # Normalize currency to ensure consistent representation
22
+ # @param currency [String] The currency string to normalize
23
+ # @return [String] The normalized currency string
24
+ def normalize_currency(currency)
25
+ return 'USD' if currency == '$' || currency.to_s.strip.upcase == 'USD'
26
+
27
+ currency.to_s.strip.upcase
28
+ end
29
+
30
+ # Get the signed amount (positive for debits, negative for credits)
31
+ # @return [Float] The signed amount
32
+ def signed_amount
33
+ @type == :debit ? @amount : -@amount
34
+ end
35
+
36
+ # Convert the entry to a ledger format string
37
+ # @return [String] The entry in ledger format
38
+ def to_ledger
39
+ # Always format as $ for consistency with source transactions
40
+ " #{@account} $#{signed_amount}"
41
+ end
42
+ end
43
+
44
+ class Transaction
45
+ attr_accessor :date, :description, :transaction_id, :status, :counterparty, :memo, :timestamp, :currency,
46
+ :source_info
47
+ attr_reader :entries, :comments, :hints
48
+
49
+ def initialize(date:, description:, transaction_id: nil, status: nil, counterparty: nil, memo: nil, timestamp: nil,
50
+ currency: 'USD', source_info: nil)
51
+ @date = date
52
+ @description = description
53
+ @transaction_id = transaction_id || SecureRandom.uuid
54
+ @status = status
55
+ @counterparty = counterparty
56
+ @memo = memo
57
+ @timestamp = timestamp
58
+ @currency = self.class.normalize_currency(currency)
59
+ @source_info = source_info
60
+ @entries = []
61
+ @comments = []
62
+ @hints = []
63
+ end
64
+
65
+ # Add an entry to the transaction
66
+ # @param account [String] The account name
67
+ # @param amount [Float] The amount (positive value)
68
+ # @param type [Symbol] Either :debit or :credit
69
+ # @return [Entry] The created entry
70
+ def add_entry(account:, amount:, type:)
71
+ entry = Entry.new(
72
+ account: account,
73
+ amount: amount,
74
+ currency: @currency,
75
+ type: type
76
+ )
77
+ @entries << entry
78
+ entry
79
+ end
80
+
81
+ # Add a debit entry
82
+ # @param account [String] The account name
83
+ # @param amount [Float] The amount (positive value)
84
+ # @return [Entry] The created entry
85
+ def add_debit(account:, amount:)
86
+ add_entry(account: account, amount: amount, type: :debit)
87
+ end
88
+
89
+ # Add a credit entry
90
+ # @param account [String] The account name
91
+ # @param amount [Float] The amount (positive value)
92
+ # @return [Entry] The created entry
93
+ def add_credit(account:, amount:)
94
+ add_entry(account: account, amount: amount, type: :credit)
95
+ end
96
+
97
+ # Add a comment to the transaction
98
+ # @param comment [String] The comment to add
99
+ # @return [Array<String>] The updated comments array
100
+ def add_comment(comment)
101
+ @comments << comment
102
+ @comments
103
+ end
104
+
105
+ # Add a hint for AI suggestions
106
+ # @param hint [String] The hint to add
107
+ # @return [Array<String>] The updated hints array
108
+ def add_hint(hint)
109
+ @hints << hint
110
+ @hints
111
+ end
112
+
113
+ # Check if the transaction is balanced (sum of debits equals sum of credits)
114
+ # @return [Boolean] True if balanced, false otherwise
115
+ def balanced?
116
+ total_debits = @entries.select { |e| e.type == :debit }.sum(&:amount)
117
+ total_credits = @entries.select { |e| e.type == :credit }.sum(&:amount)
118
+
119
+ (total_debits - total_credits).abs < 0.001 # Allow for small floating point differences
120
+ end
121
+
122
+ # Validate the transaction and return any warnings
123
+ # @return [Array<String>] Array of warning messages
124
+ def validate
125
+ warnings = []
126
+
127
+ # Check if transaction has entries
128
+ if @entries.empty?
129
+ warnings << 'Transaction has no entries'
130
+ return warnings
131
+ end
132
+
133
+ # Check if transaction is balanced
134
+ unless balanced?
135
+ total_debits = @entries.select { |e| e.type == :debit }.sum(&:amount)
136
+ total_credits = @entries.select { |e| e.type == :credit }.sum(&:amount)
137
+ warnings << "Transaction is not balanced: debits (#{total_debits}) != credits (#{total_credits})"
138
+ end
139
+
140
+ # Check if transaction has at least one debit and one credit
141
+ warnings << 'Transaction has no debits' if @entries.none? { |e| e.type == :debit }
142
+
143
+ warnings << 'Transaction has no credits' if @entries.none? { |e| e.type == :credit }
144
+
145
+ warnings
146
+ end
147
+
148
+ # Check if the transaction is valid
149
+ # @return [Boolean] True if valid, false otherwise
150
+ def valid?
151
+ validate.empty?
152
+ end
153
+
154
+ def to_s
155
+ # Build source info string if available
156
+ source_info_str = ''
157
+ if @source_info && @source_info[:file]
158
+ line_info = ''
159
+ if @source_info[:start_line] && @source_info[:end_line]
160
+ line_info = "##{@source_info[:start_line]}-#{@source_info[:end_line]}"
161
+ end
162
+ source_info_str = "Importing: #{@source_info[:file]}#{line_info}"
163
+ end
164
+
165
+ status_info = @status ? " [#{@status}]" : ''
166
+
167
+ # Build the output
168
+ output = []
169
+ output << source_info_str unless source_info_str.empty?
170
+ output << "Transaction: #{@transaction_id}#{status_info}"
171
+ output << "Date: #{@date}"
172
+ output << "Description: #{@description}" if @description
173
+
174
+ # Add entries
175
+ output << 'Entries:'
176
+ @entries.each do |entry|
177
+ output << " #{entry.type.to_s.capitalize}: #{entry.account} #{entry.amount} #{entry.currency}"
178
+ end
179
+
180
+ output << "Counterparty: #{@counterparty}" if @counterparty
181
+
182
+ # Add validation warnings if any
183
+ warnings = validate
184
+ unless warnings.empty?
185
+ output << 'Warnings:'
186
+ warnings.each do |warning|
187
+ output << " #{warning}"
188
+ end
189
+ end
190
+
191
+ # Add hints if available
192
+ if @hints && !@hints.empty?
193
+ output << 'Hints:'
194
+ @hints.each do |hint|
195
+ output << " - #{hint}"
196
+ end
197
+ end
198
+
199
+ output.join("\n")
200
+ end
201
+
202
+ # Format transaction for ledger file
203
+ def to_ledger
204
+ # Ensure the transaction is balanced before writing to ledger
205
+ unless balanced?
206
+ warnings = validate
207
+ raise "Cannot write unbalanced transaction to ledger: #{warnings.join(', ')}"
208
+ end
209
+
210
+ output = "#{@date.strftime('%Y/%m/%d')} #{@description}\n"
211
+ output += " ; Transaction ID: #{@transaction_id}\n" if @transaction_id
212
+ output += " ; Status: #{@status}\n" if @status
213
+ output += " ; Counterparty: #{@counterparty}\n" if @counterparty
214
+ output += " ; Memo: #{@memo}\n" if @memo
215
+ output += " ; Timestamp: #{@timestamp}\n" if @timestamp
216
+
217
+ # Add entries
218
+ @entries.each do |entry|
219
+ output += "#{entry.to_ledger}\n"
220
+ end
221
+
222
+ # Add custom comments
223
+ @comments.each do |comment|
224
+ output += " ; #{comment}\n"
225
+ end
226
+
227
+ output
228
+ end
229
+
230
+ # Create a reverse transaction (for recoding)
231
+ # @param new_categories [Hash] A hash of new categories to use
232
+ # @return [Transaction] The reverse transaction
233
+ def create_reverse_transaction(new_categories = nil)
234
+ # Create a new transaction with the same metadata
235
+ reverse_transaction = Transaction.new(
236
+ date: @date,
237
+ description: "Reversal: #{@description}",
238
+ transaction_id: "rev-#{@transaction_id}",
239
+ status: @status,
240
+ counterparty: @counterparty,
241
+ memo: "Reversal of transaction #{@transaction_id}",
242
+ timestamp: @timestamp,
243
+ currency: @currency,
244
+ source_info: @source_info
245
+ )
246
+
247
+ # Add the original transaction ID as a comment for easier identification
248
+ reverse_transaction.add_comment("Original Transaction ID: #{@transaction_id}")
249
+
250
+ # Copy any hints to the reverse transaction
251
+ @hints.each do |hint|
252
+ reverse_transaction.add_hint(hint)
253
+ end
254
+
255
+ # If no new categories are provided, just reverse all entries
256
+ if new_categories.nil? || new_categories.empty?
257
+ @entries.each do |entry|
258
+ if entry.type == :debit
259
+ reverse_transaction.add_credit(account: entry.account, amount: entry.amount)
260
+ else
261
+ reverse_transaction.add_debit(account: entry.account, amount: entry.amount)
262
+ end
263
+ end
264
+ return reverse_transaction
265
+ end
266
+
267
+ # Find the unknown entry to replace
268
+ unknown_categories = ['Income:Unknown', 'Expenses:Unknown']
269
+ unknown_entry = @entries.find { |e| unknown_categories.include?(e.account) }
270
+
271
+ # If there's no unknown entry, raise an error
272
+ raise 'Cannot recategorize transaction without an Unknown category' unless unknown_entry
273
+
274
+ # Calculate the total amount from the new categories
275
+ total_amount = new_categories.values.sum { |v| v.is_a?(String) ? Transaction.parse_amount(v) : v }
276
+
277
+ # Ensure the total amount matches the Unknown entry amount
278
+ if (total_amount - unknown_entry.amount).abs > 0.001
279
+ raise "Total amount of new categories (#{total_amount}) does not match the Unknown entry amount (#{unknown_entry.amount})"
280
+ end
281
+
282
+ # Add the new categories with the same type as the Unknown entry
283
+ new_categories.each do |category, amount|
284
+ amount_value = amount.is_a?(String) ? Transaction.parse_amount(amount) : amount
285
+ if unknown_entry.type == :debit
286
+ reverse_transaction.add_debit(account: category, amount: amount_value)
287
+ else
288
+ reverse_transaction.add_credit(account: category, amount: amount_value)
289
+ end
290
+ end
291
+
292
+ # Add a balancing entry for the Unknown entry (reversed)
293
+ if unknown_entry.type == :debit
294
+ reverse_transaction.add_credit(account: unknown_entry.account, amount: total_amount)
295
+ else
296
+ reverse_transaction.add_debit(account: unknown_entry.account, amount: total_amount)
297
+ end
298
+
299
+ reverse_transaction
300
+ end
301
+
302
+ # Create a Transaction from a ledger hash
303
+ # @param hash [Hash] Hash with transaction details
304
+ # @return [Transaction] Transaction object
305
+ def self.from_ledger_hash(hash)
306
+ # Extract the required fields
307
+ date = hash[:date]
308
+ description = hash[:description]
309
+
310
+ # Extract the optional fields
311
+ transaction_id = hash[:transaction_id]
312
+ status = hash[:status]
313
+ counterparty = hash[:counterparty]
314
+ memo = hash[:memo]
315
+ timestamp = hash[:timestamp]
316
+ currency = normalize_currency(hash[:currency] || 'USD')
317
+ source_info = hash[:source_info]
318
+
319
+ # Create a new Transaction
320
+ transaction = new(
321
+ date: date,
322
+ description: description,
323
+ transaction_id: transaction_id,
324
+ status: status,
325
+ counterparty: counterparty,
326
+ memo: memo,
327
+ timestamp: timestamp,
328
+ currency: currency,
329
+ source_info: source_info
330
+ )
331
+
332
+ # Add entries
333
+ if hash[:entries]
334
+ hash[:entries].each do |entry|
335
+ transaction.add_entry(
336
+ account: entry[:account],
337
+ amount: entry[:amount],
338
+ type: entry[:type]
339
+ )
340
+ end
341
+ elsif hash[:from_account] && hash[:to_account] && hash[:amount]
342
+ # Legacy format
343
+ amount = hash[:amount]
344
+ from_account = hash[:from_account]
345
+ to_account = hash[:to_account]
346
+
347
+ # Convert to new format
348
+ amount_value = amount.is_a?(String) ? parse_amount(amount) : amount
349
+
350
+ if amount_value.negative?
351
+ # Handle negative amounts
352
+ transaction.add_credit(account: from_account, amount: amount_value.abs)
353
+ transaction.add_debit(account: to_account, amount: amount_value.abs)
354
+ else
355
+ # Use the traditional approach
356
+ transaction.add_debit(account: to_account, amount: amount_value)
357
+ transaction.add_credit(account: from_account, amount: amount_value)
358
+ end
359
+ end
360
+
361
+ transaction
362
+ end
363
+
364
+ # Normalize currency to ensure consistent representation (class method)
365
+ # @param currency [String] The currency string to normalize
366
+ # @return [String] The normalized currency string
367
+ def self.normalize_currency(currency)
368
+ return 'USD' if currency == '$' || currency.to_s.strip.upcase == 'USD'
369
+
370
+ currency.to_s.strip.upcase
371
+ end
372
+
373
+ # Parse an amount string into a float
374
+ # @param amount_str [String] The amount string to parse
375
+ # @return [Float] The parsed amount
376
+ def self.parse_amount(amount_str)
377
+ return amount_str.to_f unless amount_str.is_a?(String)
378
+
379
+ # Extract the actual amount value from strings like "Checking ••1090 $10000.00" or "Income:Unknown -10000.00 USD"
380
+ case amount_str
381
+ when /\$[\-\d,\.]+/
382
+ # Handle $ format
383
+ clean_amount = amount_str.match(/\$[\-\d,\.]+/)[0]
384
+ clean_amount.gsub(/[\$,]/, '').to_f
385
+ when /([\-\d,\.]+)\s+[\$USD]+/
386
+ # Handle "100.00 USD" or "100.00 $" format
387
+ clean_amount = amount_str.match(/([\-\d,\.]+)\s+[\$USD]+/)[1]
388
+ clean_amount.delete(',').to_f
389
+ when /^[\-\d,\.]+$/
390
+ # Handle plain number format
391
+ amount_str.delete(',').to_f
392
+ else
393
+ # If it's just a category name without an amount, return 0
394
+ # This will be handled by the caller
395
+ 0.0
396
+ end
397
+ end
398
+ end
399
+ end