Class: Teri::Transaction
- Inherits:
-
Object
- Object
- Teri::Transaction
- Defined in:
- lib/teri/transaction.rb
Instance Attribute Summary collapse
-
#comments ⇒ Object
readonly
Returns the value of attribute comments.
-
#counterparty ⇒ Object
Returns the value of attribute counterparty.
-
#currency ⇒ Object
Returns the value of attribute currency.
-
#date ⇒ Object
Returns the value of attribute date.
-
#description ⇒ Object
Returns the value of attribute description.
-
#entries ⇒ Object
readonly
Returns the value of attribute entries.
-
#hints ⇒ Object
readonly
Returns the value of attribute hints.
-
#memo ⇒ Object
Returns the value of attribute memo.
-
#source_info ⇒ Object
Returns the value of attribute source_info.
-
#status ⇒ Object
Returns the value of attribute status.
-
#timestamp ⇒ Object
Returns the value of attribute timestamp.
-
#transaction_id ⇒ Object
Returns the value of attribute transaction_id.
Class Method Summary collapse
-
.from_ledger_hash(hash) ⇒ Transaction
Create a Transaction from a ledger hash.
-
.normalize_currency(currency) ⇒ String
Normalize currency to ensure consistent representation (class method).
-
.parse_amount(amount_str) ⇒ Float
Parse an amount string into a float.
Instance Method Summary collapse
-
#add_comment(comment) ⇒ Array<String>
Add a comment to the transaction.
-
#add_credit(account:, amount:) ⇒ Entry
Add a credit entry.
-
#add_debit(account:, amount:) ⇒ Entry
Add a debit entry.
-
#add_entry(account:, amount:, type:) ⇒ Entry
Add an entry to the transaction.
-
#add_hint(hint) ⇒ Array<String>
Add a hint for AI suggestions.
-
#balanced? ⇒ Boolean
Check if the transaction is balanced (sum of debits equals sum of credits).
-
#create_reverse_transaction(new_categories = nil) ⇒ Transaction
Create a reverse transaction (for recoding).
-
#initialize(date:, description:, transaction_id: nil, status: nil, counterparty: nil, memo: nil, timestamp: nil, currency: 'USD', source_info: nil) ⇒ Transaction
constructor
A new instance of Transaction.
-
#to_ledger ⇒ Object
Format transaction for ledger file.
- #to_s ⇒ Object
-
#valid? ⇒ Boolean
Check if the transaction is valid.
-
#validate ⇒ Array<String>
Validate the transaction and return any warnings.
Constructor Details
#initialize(date:, description:, transaction_id: nil, status: nil, counterparty: nil, memo: nil, timestamp: nil, currency: 'USD', source_info: nil) ⇒ Transaction
Returns a new instance of Transaction.
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/teri/transaction.rb', line 49 def initialize(date:, description:, transaction_id: nil, status: nil, counterparty: nil, memo: nil, timestamp: nil, currency: 'USD', source_info: nil) @date = date @description = description @transaction_id = transaction_id || SecureRandom.uuid @status = status @counterparty = counterparty @memo = memo = @currency = self.class.normalize_currency(currency) @source_info = source_info @entries = [] @comments = [] @hints = [] end |
Instance Attribute Details
#comments ⇒ Object (readonly)
Returns the value of attribute comments.
47 48 49 |
# File 'lib/teri/transaction.rb', line 47 def comments @comments end |
#counterparty ⇒ Object
Returns the value of attribute counterparty.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def counterparty @counterparty end |
#currency ⇒ Object
Returns the value of attribute currency.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def currency @currency end |
#date ⇒ Object
Returns the value of attribute date.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def date @date end |
#description ⇒ Object
Returns the value of attribute description.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def description @description end |
#entries ⇒ Object (readonly)
Returns the value of attribute entries.
47 48 49 |
# File 'lib/teri/transaction.rb', line 47 def entries @entries end |
#hints ⇒ Object (readonly)
Returns the value of attribute hints.
47 48 49 |
# File 'lib/teri/transaction.rb', line 47 def hints @hints end |
#memo ⇒ Object
Returns the value of attribute memo.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def memo @memo end |
#source_info ⇒ Object
Returns the value of attribute source_info.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def source_info @source_info end |
#status ⇒ Object
Returns the value of attribute status.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def status @status end |
#timestamp ⇒ Object
Returns the value of attribute timestamp.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def end |
#transaction_id ⇒ Object
Returns the value of attribute transaction_id.
45 46 47 |
# File 'lib/teri/transaction.rb', line 45 def transaction_id @transaction_id end |
Class Method Details
.from_ledger_hash(hash) ⇒ Transaction
Create a Transaction from a ledger hash
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 |
# File 'lib/teri/transaction.rb', line 305 def self.from_ledger_hash(hash) # Extract the required fields date = hash[:date] description = hash[:description] # Extract the optional fields transaction_id = hash[:transaction_id] status = hash[:status] counterparty = hash[:counterparty] memo = hash[:memo] = hash[:timestamp] currency = normalize_currency(hash[:currency] || 'USD') source_info = hash[:source_info] # Create a new Transaction transaction = new( date: date, description: description, transaction_id: transaction_id, status: status, counterparty: counterparty, memo: memo, timestamp: , currency: currency, source_info: source_info ) # Add entries if hash[:entries] hash[:entries].each do |entry| transaction.add_entry( account: entry[:account], amount: entry[:amount], type: entry[:type] ) end elsif hash[:from_account] && hash[:to_account] && hash[:amount] # Legacy format amount = hash[:amount] from_account = hash[:from_account] to_account = hash[:to_account] # Convert to new format amount_value = amount.is_a?(String) ? parse_amount(amount) : amount if amount_value.negative? # Handle negative amounts transaction.add_credit(account: from_account, amount: amount_value.abs) transaction.add_debit(account: to_account, amount: amount_value.abs) else # Use the traditional approach transaction.add_debit(account: to_account, amount: amount_value) transaction.add_credit(account: from_account, amount: amount_value) end end transaction end |
.normalize_currency(currency) ⇒ String
Normalize currency to ensure consistent representation (class method)
367 368 369 370 371 |
# File 'lib/teri/transaction.rb', line 367 def self.normalize_currency(currency) return 'USD' if currency == '$' || currency.to_s.strip.upcase == 'USD' currency.to_s.strip.upcase end |
.parse_amount(amount_str) ⇒ Float
Parse an amount string into a float
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/teri/transaction.rb', line 376 def self.parse_amount(amount_str) return amount_str.to_f unless amount_str.is_a?(String) # Extract the actual amount value from strings like "Checking ••1090 $10000.00" or "Income:Unknown -10000.00 USD" case amount_str when /\$[\-\d,\.]+/ # Handle $ format clean_amount = amount_str.match(/\$[\-\d,\.]+/)[0] clean_amount.gsub(/[\$,]/, '').to_f when /([\-\d,\.]+)\s+[\$USD]+/ # Handle "100.00 USD" or "100.00 $" format clean_amount = amount_str.match(/([\-\d,\.]+)\s+[\$USD]+/)[1] clean_amount.delete(',').to_f when /^[\-\d,\.]+$/ # Handle plain number format amount_str.delete(',').to_f else # If it's just a category name without an amount, return 0 # This will be handled by the caller 0.0 end end |
Instance Method Details
#add_comment(comment) ⇒ Array<String>
Add a comment to the transaction
100 101 102 103 |
# File 'lib/teri/transaction.rb', line 100 def add_comment(comment) @comments << comment @comments end |
#add_credit(account:, amount:) ⇒ Entry
Add a credit entry
93 94 95 |
# File 'lib/teri/transaction.rb', line 93 def add_credit(account:, amount:) add_entry(account: account, amount: amount, type: :credit) end |
#add_debit(account:, amount:) ⇒ Entry
Add a debit entry
85 86 87 |
# File 'lib/teri/transaction.rb', line 85 def add_debit(account:, amount:) add_entry(account: account, amount: amount, type: :debit) end |
#add_entry(account:, amount:, type:) ⇒ Entry
Add an entry to the transaction
70 71 72 73 74 75 76 77 78 79 |
# File 'lib/teri/transaction.rb', line 70 def add_entry(account:, amount:, type:) entry = Entry.new( account: account, amount: amount, currency: @currency, type: type ) @entries << entry entry end |
#add_hint(hint) ⇒ Array<String>
Add a hint for AI suggestions
108 109 110 111 |
# File 'lib/teri/transaction.rb', line 108 def add_hint(hint) @hints << hint @hints end |
#balanced? ⇒ Boolean
Check if the transaction is balanced (sum of debits equals sum of credits)
115 116 117 118 119 120 |
# File 'lib/teri/transaction.rb', line 115 def balanced? total_debits = @entries.select { |e| e.type == :debit }.sum(&:amount) total_credits = @entries.select { |e| e.type == :credit }.sum(&:amount) (total_debits - total_credits).abs < 0.001 # Allow for small floating point differences end |
#create_reverse_transaction(new_categories = nil) ⇒ Transaction
Create a reverse transaction (for recoding)
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/teri/transaction.rb', line 233 def create_reverse_transaction(new_categories = nil) # Create a new transaction with the same metadata reverse_transaction = Transaction.new( date: @date, description: "Reversal: #{@description}", transaction_id: "rev-#{@transaction_id}", status: @status, counterparty: @counterparty, memo: "Reversal of transaction #{@transaction_id}", timestamp: , currency: @currency, source_info: @source_info ) # Add the original transaction ID as a comment for easier identification reverse_transaction.add_comment("Original Transaction ID: #{@transaction_id}") # Copy any hints to the reverse transaction @hints.each do |hint| reverse_transaction.add_hint(hint) end # If no new categories are provided, just reverse all entries if new_categories.nil? || new_categories.empty? @entries.each do |entry| if entry.type == :debit reverse_transaction.add_credit(account: entry.account, amount: entry.amount) else reverse_transaction.add_debit(account: entry.account, amount: entry.amount) end end return reverse_transaction end # Find the unknown entry to replace unknown_categories = ['Income:Unknown', 'Expenses:Unknown'] unknown_entry = @entries.find { |e| unknown_categories.include?(e.account) } # If there's no unknown entry, raise an error raise 'Cannot recategorize transaction without an Unknown category' unless unknown_entry # Calculate the total amount from the new categories total_amount = new_categories.values.sum { |v| v.is_a?(String) ? Transaction.parse_amount(v) : v } # Ensure the total amount matches the Unknown entry amount if (total_amount - unknown_entry.amount).abs > 0.001 raise "Total amount of new categories (#{total_amount}) does not match the Unknown entry amount (#{unknown_entry.amount})" end # Add the new categories with the same type as the Unknown entry new_categories.each do |category, amount| amount_value = amount.is_a?(String) ? Transaction.parse_amount(amount) : amount if unknown_entry.type == :debit reverse_transaction.add_debit(account: category, amount: amount_value) else reverse_transaction.add_credit(account: category, amount: amount_value) end end # Add a balancing entry for the Unknown entry (reversed) if unknown_entry.type == :debit reverse_transaction.add_credit(account: unknown_entry.account, amount: total_amount) else reverse_transaction.add_debit(account: unknown_entry.account, amount: total_amount) end reverse_transaction end |
#to_ledger ⇒ Object
Format transaction for ledger file
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/teri/transaction.rb', line 203 def to_ledger # Ensure the transaction is balanced before writing to ledger unless balanced? warnings = validate raise "Cannot write unbalanced transaction to ledger: #{warnings.join(', ')}" end output = "#{@date.strftime('%Y/%m/%d')} #{@description}\n" output += " ; Transaction ID: #{@transaction_id}\n" if @transaction_id output += " ; Status: #{@status}\n" if @status output += " ; Counterparty: #{@counterparty}\n" if @counterparty output += " ; Memo: #{@memo}\n" if @memo output += " ; Timestamp: #{@timestamp}\n" if # Add entries @entries.each do |entry| output += "#{entry.to_ledger}\n" end # Add custom comments @comments.each do |comment| output += " ; #{comment}\n" end output end |
#to_s ⇒ Object
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/teri/transaction.rb', line 154 def to_s # Build source info string if available source_info_str = '' if @source_info && @source_info[:file] line_info = '' if @source_info[:start_line] && @source_info[:end_line] line_info = "##{@source_info[:start_line]}-#{@source_info[:end_line]}" end source_info_str = "Importing: #{@source_info[:file]}#{line_info}" end status_info = @status ? " [#{@status}]" : '' # Build the output output = [] output << source_info_str unless source_info_str.empty? output << "Transaction: #{@transaction_id}#{status_info}" output << "Date: #{@date}" output << "Description: #{@description}" if @description # Add entries output << 'Entries:' @entries.each do |entry| output << " #{entry.type.to_s.capitalize}: #{entry.account} #{entry.amount} #{entry.currency}" end output << "Counterparty: #{@counterparty}" if @counterparty # Add validation warnings if any warnings = validate unless warnings.empty? output << 'Warnings:' warnings.each do |warning| output << " #{warning}" end end # Add hints if available if @hints && !@hints.empty? output << 'Hints:' @hints.each do |hint| output << " - #{hint}" end end output.join("\n") end |
#valid? ⇒ Boolean
Check if the transaction is valid
150 151 152 |
# File 'lib/teri/transaction.rb', line 150 def valid? validate.empty? end |
#validate ⇒ Array<String>
Validate the transaction and return any warnings
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/teri/transaction.rb', line 124 def validate warnings = [] # Check if transaction has entries if @entries.empty? warnings << 'Transaction has no entries' return warnings end # Check if transaction is balanced unless balanced? total_debits = @entries.select { |e| e.type == :debit }.sum(&:amount) total_credits = @entries.select { |e| e.type == :credit }.sum(&:amount) warnings << "Transaction is not balanced: debits (#{total_debits}) != credits (#{total_credits})" end # Check if transaction has at least one debit and one credit warnings << 'Transaction has no debits' if @entries.none? { |e| e.type == :debit } warnings << 'Transaction has no credits' if @entries.none? { |e| e.type == :credit } warnings end |