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.
- checksums.yaml +7 -0
- data/LICENSE.txt +25 -0
- data/README.md +285 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/teri +5 -0
- data/lib/teri/accounting.rb +310 -0
- data/lib/teri/ai_integration.rb +109 -0
- data/lib/teri/category_manager.rb +92 -0
- data/lib/teri/cli.rb +71 -0
- data/lib/teri/ledger.rb +332 -0
- data/lib/teri/openai_client.rb +229 -0
- data/lib/teri/report_generator.rb +258 -0
- data/lib/teri/transaction.rb +399 -0
- data/lib/teri/transaction_coder.rb +338 -0
- data/lib/teri/version.rb +3 -0
- data/lib/teri.rb +10 -0
- metadata +105 -0
@@ -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
|