Skip to content

Commit

Permalink
New Beancount parser
Browse files Browse the repository at this point in the history
  • Loading branch information
benprew committed Feb 21, 2023
1 parent d542b3b commit 7119459
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 2 deletions.
1 change: 1 addition & 0 deletions lib/reckon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
require_relative 'reckon/date_column'
require_relative 'reckon/money'
require_relative 'reckon/ledger_parser'
require_relative 'reckon/beancount_parser'
require_relative 'reckon/csv_parser'
require_relative 'reckon/options'
require_relative 'reckon/app'
2 changes: 1 addition & 1 deletion lib/reckon/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def initialize(opts = {})
self.options[:currency] ||= '$'
@csv_parser = CSVParser.new( options )
@matcher = CosineSimilarity.new(options)
@parser = LedgerParser.new
@parser = options[:format] =~ /beancount/i ? BeancountParser.new : LedgerParser.new
learn!
end

Expand Down
150 changes: 150 additions & 0 deletions lib/reckon/beancount_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
require 'rubygems'
require 'date'

module Reckon
class BeancountParser

attr_accessor :entries

def initialize(options = {})
@options = options
@date_format = options[:ledger_date_format] || options[:date_format] || '%Y-%m-%d'
end

# 2015-01-01 * "Opening Balance for checking account"
# Assets:US:BofA:Checking 3490.52 USD
# Equity:Opening-Balances -3490.52 USD

# input is an object that response to #each_line,
# (i.e. a StringIO or an IO object)
def parse(input)
entries = []
comment_chars = ';#%*|'
new_entry = {}

input.each_line do |entry|

next if entry =~ /^\s*[#{comment_chars}]/

m = entry.match(%r{
^
(\d+[\d/-]+) # date
\s+
([*!])? # type
\s*
("[^"]*")? # description (optional)
\s*
("[^"]*")? # notes (optional)
# tags (not implemented)
}x)

# (date, type, code, description), type and code are optional
if (m)
add_entry(entries, new_entry)
new_entry = {
date: try_parse_date(m[1]),
type: m[2] || "",
desc: trim_quote(m[3]),
notes: trim_quote(m[4]),
accounts: []
}
elsif entry =~ /^\s*$/ && new_entry[:date]
add_entry(entries, new_entry)
new_entry = {}
elsif new_entry[:date] && entry =~ /^\s+/
LOGGER.info("Adding new account #{entry}")
new_entry[:accounts] << parse_account_line(entry)
else
LOGGER.info("Unknown entry type: #{entry}")
add_entry(entries, new_entry)
new_entry = {}
end

end
entries
end

def format_row(row, line1, line2)
out = %Q{#{row[:pretty_date]} * "#{row[:description]}" "#{row[:note]}\n}
out += "\t#{line1.first}\t\t\t#{line1.last}\n"
out += "\t#{line2.first}\t\t\t#{line2.last}\n\n"
out
end

private

# remove leading and trailing quote character (")
def trim_quote(str)
return str if !str
str.gsub(/^"([^"]*)"$/, '\1')
end

def add_entry(entries, entry)
return unless entry[:date] && entry[:accounts].length > 1

entry[:accounts] = balance(entry[:accounts])
entries << entry
end

def try_parse_date(date_str)
date = Date.parse(date_str)
return nil if date.year > 9999 || date.year < 1000

date
rescue ArgumentError
nil
end

def parse_account_line(entry)
# TODO handle buying stocks
# Assets:US:ETrade:VHT 19 VHT {132.32 USD, 2017-08-27}
(account_name, rest) = entry.strip.split(/\s{2,}|\t+/, 2)

if rest.nil? || rest.empty?
return {
name: account_name,
amount: clean_money("")
}
end

value = if rest =~ /{/
(qty, dollar_value, date) = rest.split(/[{,]/)
(qty.to_f * dollar_value.to_f).to_s
else
rest
end

return {
name: account_name,
amount: clean_money(value || "")
}
end

def balance(accounts)
return accounts unless accounts.any? { |i| i[:amount].nil? }

sum = accounts.reduce(0) { |m, n| m + (n[:amount] || 0) }
count = 0
accounts.each do |account|
next unless account[:amount].nil?

count += 1
account[:amount] = -sum
end
if count > 1
puts "Warning: unparsable entry due to more than one missing money value."
p accounts
puts
end

accounts
end

def clean_money(money)
return nil if money.nil? || money.empty?

money.gsub(/[^0-9.-]/, '').to_f
end
end
end

4 changes: 4 additions & 0 deletions lib/reckon/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ def self.parse(args = ARGV, stdin = $stdin)
options[:suffixed] = e
end

opts.on("", "--ledger-format FORMAT", "Output/Learn format: BEANCOUNT or LEDGER. Default: LEDGER") do |n|
options[:format] = n
end

opts.on_tail("-h", "--help", "Show this message") do
puts opts
exit
Expand Down
2 changes: 1 addition & 1 deletion lib/reckon/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Reckon
VERSION="0.8.1"
VERSION="0.9.0-beta"
end

0 comments on commit 7119459

Please sign in to comment.