rledger 0.0.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/.gitignore +20 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +47 -0
- data/Rakefile +1 -0
- data/bin/rledger +6 -0
- data/lib/rledger/ledger/amount.rb +71 -0
- data/lib/rledger/ledger/post.rb +59 -0
- data/lib/rledger/ledger/reconciliation.rb +52 -0
- data/lib/rledger/ledger/transaction.rb +152 -0
- data/lib/rledger/report/balance.rb +42 -0
- data/lib/rledger/report/statement.rb +74 -0
- data/lib/rledger/version.rb +3 -0
- data/lib/rledger.rb +96 -0
- data/rledger.gemspec +30 -0
- data/test/rledger/amount_test.rb +60 -0
- data/test/rledger/post_test.rb +52 -0
- data/test/rledger/reconciliation_test.rb +45 -0
- data/test/rledger/transaction_test.rb +68 -0
- metadata +100 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 7147808ab081244140f4404b9c6a289a9595737a
|
4
|
+
data.tar.gz: f7f0128c167f34e2f93450877a5cbd9668ee020c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: bec37a601ef9b3d8a722165d7823632887e9dc6ab74416cddc10bd4beffffd415af00375ed552bf1ec7a485f024daebce076ed7d1679de81fb94c5c6e61877f7
|
7
|
+
data.tar.gz: 9b055a9d990765af6b66fdbfc01775bcfd6fa751c70683b229df0115b4cc665d21e4444bd599bb0c5240404adc9e8b115c150bb508445d723ed99ed7c189ca15
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Adolfo Villafiorita
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# Rledger
|
2
|
+
|
3
|
+
Rledger reads ledgers in the [Ledger CLI](http://www.ledger-cli.org)
|
4
|
+
format and performs simple operations on them.
|
5
|
+
|
6
|
+
Only the basic format is recognized: **do not expect this gem to
|
7
|
+
perform well with complex ledger files.** The goal, in fact, is not
|
8
|
+
that of building a clone of [ledger](http://www.ledger-cli.org), but
|
9
|
+
being able to read simple ledger files in ruby.
|
10
|
+
|
11
|
+
Please signal any bug or issues you might find through the [Github
|
12
|
+
Repository](http://github.io/avillafiorita/rledger)
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add this line to your application's Gemfile:
|
17
|
+
|
18
|
+
gem 'rledger'
|
19
|
+
|
20
|
+
And then execute:
|
21
|
+
|
22
|
+
$ bundle
|
23
|
+
|
24
|
+
Or install it yourself as:
|
25
|
+
|
26
|
+
$ gem install rledger
|
27
|
+
|
28
|
+
## Usage
|
29
|
+
|
30
|
+
Try the following commands on a ledger file:
|
31
|
+
|
32
|
+
rledger --command statement ledger.txt
|
33
|
+
rledger --command balance ledger.txt
|
34
|
+
|
35
|
+
The mileage of the gem will vary, according to the complexity of the
|
36
|
+
format.
|
37
|
+
|
38
|
+
Please signal any bug report through Github.
|
39
|
+
|
40
|
+
|
41
|
+
## Contributing
|
42
|
+
|
43
|
+
1. Fork it
|
44
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
45
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
46
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
47
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/rledger
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
|
3
|
+
module Rledger
|
4
|
+
# Amount stores an amount, as an Hash currency => value.
|
5
|
+
#
|
6
|
+
# The peculiar storage simplifies operations on multiple currencies
|
7
|
+
# (adding different currencies is equivalent to merging hashes)
|
8
|
+
#
|
9
|
+
class Amount
|
10
|
+
def initialize
|
11
|
+
@amount = Hash.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def parse(s)
|
15
|
+
currency = "([a-zA-Z$]+|)"
|
16
|
+
amount = "(-?[0-9]+\\.?[0-9]+|)"
|
17
|
+
ob = "[\t ]*"
|
18
|
+
|
19
|
+
match = Regexp.new(currency + ob + amount).match(s)
|
20
|
+
|
21
|
+
if match
|
22
|
+
currency = match[1]
|
23
|
+
amount = match[2] == "" ? BigDecimal.new('0.00') : BigDecimal.new(match[2])
|
24
|
+
@amount[currency] = amount
|
25
|
+
true
|
26
|
+
else
|
27
|
+
false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_s
|
32
|
+
@amount.keys.collect { |key| key + " " + "%.2f" % @amount[key] }.join(", ")
|
33
|
+
end
|
34
|
+
|
35
|
+
def add!(other_amount)
|
36
|
+
@amount.merge!(other_amount.hash) { |key, oldval, newval| @amount[key] = oldval + newval }
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
def multiply!(factor)
|
41
|
+
@amount.keys.map { |x| @amount[x] = factor * @amount[x] }
|
42
|
+
self
|
43
|
+
end
|
44
|
+
|
45
|
+
def single_currency?
|
46
|
+
@amount.keys.size == 1
|
47
|
+
end
|
48
|
+
|
49
|
+
def currencies
|
50
|
+
@amount.keys
|
51
|
+
end
|
52
|
+
|
53
|
+
def amount_of currency
|
54
|
+
@amount[currency]
|
55
|
+
end
|
56
|
+
|
57
|
+
# good only if single amount
|
58
|
+
def amount
|
59
|
+
@amount[@amount.keys[0]]
|
60
|
+
end
|
61
|
+
|
62
|
+
# good only if single currency
|
63
|
+
def currency
|
64
|
+
@amount.keys[0]
|
65
|
+
end
|
66
|
+
|
67
|
+
def hash
|
68
|
+
@amount
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require_relative 'amount'
|
2
|
+
|
3
|
+
module Rledger
|
4
|
+
# Entry contains the specification of an operation on an account
|
5
|
+
#
|
6
|
+
# Entries are the basis for Transactions.
|
7
|
+
#
|
8
|
+
# We allow for operations with no amount, in which case the amount
|
9
|
+
# is computed from remaining entries in the Transaction (see the
|
10
|
+
# class Transaction)
|
11
|
+
#
|
12
|
+
class Post
|
13
|
+
attr_accessor :voice, :amount, :comment
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@voice = ""
|
17
|
+
@amount = Amount.new
|
18
|
+
@comment = ""
|
19
|
+
end
|
20
|
+
|
21
|
+
def parse(s)
|
22
|
+
voice = "([a-zA-Z:-_!@']+)"
|
23
|
+
amount = "([^;]*|)" # a regexp which allows to move to the next token
|
24
|
+
comment = ";? ?(.*|)"
|
25
|
+
b = "[\t ]+"
|
26
|
+
ob = "[\t ]*"
|
27
|
+
|
28
|
+
match = Regexp.new(b + voice + ob + amount + ob + comment).match(s)
|
29
|
+
|
30
|
+
if match
|
31
|
+
@voice = match[1]
|
32
|
+
@amount.parse(match[2])
|
33
|
+
@derived = match[2] == ""
|
34
|
+
@comment = match[3]
|
35
|
+
true
|
36
|
+
else
|
37
|
+
false
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def derived?
|
42
|
+
@derived
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_s
|
46
|
+
"\t#{@voice}#{amount_to_s}\t#{comment_to_s}\n"
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def amount_to_s
|
52
|
+
derived? ? "" : "\t#{@amount.to_s}"
|
53
|
+
end
|
54
|
+
|
55
|
+
def comment_to_s
|
56
|
+
@comment == "" ? "" : "; #{@comment}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Rledger
|
2
|
+
# Reconciliation maintains the reconciliation status of a Transaction
|
3
|
+
class Reconciliation
|
4
|
+
attr_reader :status
|
5
|
+
|
6
|
+
def pending!
|
7
|
+
@status = '!'
|
8
|
+
end
|
9
|
+
|
10
|
+
def pending?
|
11
|
+
@status == '!'
|
12
|
+
end
|
13
|
+
|
14
|
+
def discarded!
|
15
|
+
@status = '?'
|
16
|
+
end
|
17
|
+
|
18
|
+
def discarded?
|
19
|
+
@status == '?'
|
20
|
+
end
|
21
|
+
|
22
|
+
def reconciled!
|
23
|
+
@status = '*'
|
24
|
+
end
|
25
|
+
|
26
|
+
def reconciled?
|
27
|
+
@status == '*'
|
28
|
+
end
|
29
|
+
|
30
|
+
def unknown!
|
31
|
+
@status = ''
|
32
|
+
end
|
33
|
+
|
34
|
+
def unknown?
|
35
|
+
@status == ''
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_s
|
39
|
+
@status
|
40
|
+
end
|
41
|
+
|
42
|
+
def parse(s)
|
43
|
+
case s
|
44
|
+
when "*" then reconciled!
|
45
|
+
when "!" then pending!
|
46
|
+
when "?" then discarded!
|
47
|
+
when "" then unknown!
|
48
|
+
else unknown!
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
require 'Date'
|
2
|
+
require 'bigdecimal'
|
3
|
+
|
4
|
+
require_relative 'reconciliation'
|
5
|
+
require_relative 'post'
|
6
|
+
|
7
|
+
module Rledger
|
8
|
+
# Transaction base contains the basic elements of any transaction
|
9
|
+
class TransactionBase
|
10
|
+
attr_accessor :date, :id, :reconciliation, :payee
|
11
|
+
|
12
|
+
# initialize variables with the correct types
|
13
|
+
def initialize
|
14
|
+
@date = Date.new
|
15
|
+
@id = ""
|
16
|
+
@reconciliation = Reconciliation.new
|
17
|
+
@payee = ""
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_s
|
21
|
+
"#{@date.strftime("%d/%m/%Y")}#{rec_to_s}#{id_to_s}#{payee_to_s}\n"
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_qif(account)
|
25
|
+
"D#{@date}\n" +
|
26
|
+
(@payee != "" ? "P#{@payee}\n" : "") +
|
27
|
+
(@id != "" ? "N#{@id}\n" : "")
|
28
|
+
end
|
29
|
+
|
30
|
+
def parse(s)
|
31
|
+
# regular expressions of various components of a transaction base
|
32
|
+
date = "([0-9][0-9]/[0-9][0-9]/[0-9][0-9][0-9][0-9])"
|
33
|
+
ob = "[\t ]*"
|
34
|
+
rec = "([!?*]|)"
|
35
|
+
id = "(\\([^)]+\\)|)"
|
36
|
+
payee = "(.*)"
|
37
|
+
|
38
|
+
match = Regexp.new(date + ob + rec + ob + id + ob + payee).match(s)
|
39
|
+
if match
|
40
|
+
@date = s_to_date(match[1]) # TODO: replace with Chronic o Date.parse
|
41
|
+
@reconciliation.parse(match[2])
|
42
|
+
@id = match[3] == "" ? "" : match[3][1..-2] # so that id is always a string
|
43
|
+
@payee = match[4]
|
44
|
+
true
|
45
|
+
else
|
46
|
+
false
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def id_to_s
|
53
|
+
@id == "" ? "" : "\t(#{@id})"
|
54
|
+
end
|
55
|
+
|
56
|
+
def rec_to_s
|
57
|
+
@reconciliation.unknown? ? "" : "\t#{@reconciliation.to_s}"
|
58
|
+
end
|
59
|
+
|
60
|
+
def payee_to_s
|
61
|
+
@payee == "" ? "" : "\t#{@payee}"
|
62
|
+
end
|
63
|
+
|
64
|
+
def s_to_date(s)
|
65
|
+
day, month, year = s.split("/");
|
66
|
+
Date.civil(year.to_i, month.to_i, day.to_i)
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
# A transaction models a multiple entry transactions
|
72
|
+
# It should contain a minimum of two entries
|
73
|
+
class Transaction < TransactionBase
|
74
|
+
attr_accessor :posts
|
75
|
+
|
76
|
+
def initialize
|
77
|
+
super
|
78
|
+
@posts = []
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.read(filename)
|
82
|
+
transactions = []
|
83
|
+
file_string = IO.read(filename)
|
84
|
+
array = file_string.split(/\n([\t ]*\n)+/) # ignore empty lines (possibly with blanks)
|
85
|
+
array.each do |record|
|
86
|
+
if record =~ /^[0-9]/
|
87
|
+
t = Transaction.new
|
88
|
+
t.parse(record)
|
89
|
+
transactions << t
|
90
|
+
end
|
91
|
+
end
|
92
|
+
transactions
|
93
|
+
end
|
94
|
+
|
95
|
+
def parse(s)
|
96
|
+
lines = s.split("\n")
|
97
|
+
super(lines[0]) # call super.parse!
|
98
|
+
lines[1..-1].each do |line|
|
99
|
+
p = Post.new
|
100
|
+
p.parse(line)
|
101
|
+
@posts << p
|
102
|
+
end
|
103
|
+
|
104
|
+
# fix the derived amount in the derived entry (if there is one)
|
105
|
+
# TODO Raise a lot of errors:
|
106
|
+
# - the other entries are not in a single currency
|
107
|
+
# - there is more than a derived entry
|
108
|
+
p = @posts.select { |x| x.derived? }
|
109
|
+
if p[0] != nil then
|
110
|
+
p[0].amount = total_no_derived.multiply!(BigDecimal.new(-1))
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def total_no_derived
|
115
|
+
a = Amount.new
|
116
|
+
@posts.each do |e|
|
117
|
+
a.add!(e.amount) if not e.derived?
|
118
|
+
end
|
119
|
+
a
|
120
|
+
end
|
121
|
+
|
122
|
+
def posts_with voice
|
123
|
+
@posts.select { |e| e.voice == voice }
|
124
|
+
end
|
125
|
+
|
126
|
+
def posts_without voice
|
127
|
+
@posts.select { |e| e.voice != voice }
|
128
|
+
end
|
129
|
+
|
130
|
+
def contains? voice
|
131
|
+
posts_with(voice).size > 0
|
132
|
+
end
|
133
|
+
|
134
|
+
def to_s
|
135
|
+
accumulator = ""
|
136
|
+
@posts.each { |post| accumulator << post.to_s }
|
137
|
+
super.to_s + accumulator + "\n"
|
138
|
+
end
|
139
|
+
|
140
|
+
def to_qif(account)
|
141
|
+
accumulator = super.to_s
|
142
|
+
@posts.each { |post|
|
143
|
+
accumulator <<
|
144
|
+
"L[#{post.item}]\n" +
|
145
|
+
"$#{post.amount}\n" +
|
146
|
+
"M#{post.comment}\n"
|
147
|
+
}
|
148
|
+
accumulator + "^"
|
149
|
+
end
|
150
|
+
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Rledger
|
2
|
+
# Balance computes the balance of leaves (at the moment)
|
3
|
+
class Balance
|
4
|
+
def initialize(transactions)
|
5
|
+
@transactions = transactions
|
6
|
+
end
|
7
|
+
|
8
|
+
def compute
|
9
|
+
@balance = Hash.new
|
10
|
+
|
11
|
+
@transactions.each do |t|
|
12
|
+
t.posts.each do |p|
|
13
|
+
elements = disaggregate p.voice
|
14
|
+
|
15
|
+
elements.each do |element|
|
16
|
+
@balance[element] ? @balance[element].add!(p.amount) : @balance[element] = p.amount
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_s
|
23
|
+
@balance.keys.sort.map { |k| puts "#{k} #{@balance[k]}" }
|
24
|
+
""
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# return an array of all the components of a voice
|
30
|
+
# Expenses:Dining:Fish -> [Expenses, Expenses:Dining, Expenses:Dining:Fish ]
|
31
|
+
def disaggregate voice
|
32
|
+
output = []
|
33
|
+
aggregator = ""
|
34
|
+
voice.split(":").each do |element|
|
35
|
+
aggregator = aggregator == "" ? element : aggregator + ":" + element
|
36
|
+
output << aggregator
|
37
|
+
end
|
38
|
+
output
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Rledger
|
2
|
+
class Statement
|
3
|
+
def initialize(transactions)
|
4
|
+
@transactions = transactions
|
5
|
+
end
|
6
|
+
|
7
|
+
def compute voice
|
8
|
+
@statement = []
|
9
|
+
cumulative = Amount.new
|
10
|
+
|
11
|
+
@transactions.each do |transaction|
|
12
|
+
if transaction.contains? voice
|
13
|
+
|
14
|
+
# the cumulative is computed by adding all posts with voice
|
15
|
+
#
|
16
|
+
# this is to ensure cumulative is in the same currency of
|
17
|
+
# voice (if we used the currency in the posts not containing
|
18
|
+
# voice to compute the cumulative, we would risk using
|
19
|
+
# different currencies, if the voice appears in multi
|
20
|
+
# currency transactions
|
21
|
+
transaction.posts_with(voice).each do |post|
|
22
|
+
cumulative.add!(post.amount)
|
23
|
+
end
|
24
|
+
|
25
|
+
# This is to generate a new amount per line
|
26
|
+
# (if we used cumulative instead, the array will
|
27
|
+
# insert a reference to the object and since add! has a side effect,
|
28
|
+
# we would build N-references to the same object, which has the last
|
29
|
+
# value assigned
|
30
|
+
line_total = Amount.new
|
31
|
+
line_total.add!(cumulative)
|
32
|
+
|
33
|
+
complement = transaction.posts_without(voice)
|
34
|
+
statement_line = { :date => transaction.date,
|
35
|
+
:id => transaction.id,
|
36
|
+
:payee => transaction.payee,
|
37
|
+
:voice => complement.size > 1 ? "-- split --" : complement[0].voice,
|
38
|
+
:amount => complement[0].amount,
|
39
|
+
:cumulative => line_total }
|
40
|
+
@statement << statement_line
|
41
|
+
|
42
|
+
# transaction.posts_without(voice).each do |post|
|
43
|
+
# # This is to generate a new amount per line
|
44
|
+
# # (if we used cumulative instead, the array will
|
45
|
+
# # insert a reference to the object and since add! has a side effect,
|
46
|
+
# # we would build N-references to the same object, which has the last
|
47
|
+
# # value assigned
|
48
|
+
# line_total = Amount.new
|
49
|
+
# line_total.add!(cumulative)
|
50
|
+
#
|
51
|
+
# statement_line = { :date => transaction.date,
|
52
|
+
# :id => transaction.id,
|
53
|
+
# :payee => transaction.payee,
|
54
|
+
# :voice => post.voice,
|
55
|
+
# :amount => post.amount,
|
56
|
+
# :cumulative => line_total }
|
57
|
+
# @statement << statement_line
|
58
|
+
# end
|
59
|
+
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
def to_s
|
66
|
+
@statement.each do |line|
|
67
|
+
printf "%10s %5.5s %-30.30s %-30.30s %10s %10s\n",
|
68
|
+
line[:date], line[:id], line[:payee],
|
69
|
+
line[:voice], line[:amount], line[:cumulative]
|
70
|
+
end
|
71
|
+
""
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/lib/rledger.rb
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
require_relative "rledger/version"
|
2
|
+
require_relative "rledger/ledger/transaction"
|
3
|
+
require_relative "rledger/report/balance"
|
4
|
+
require_relative "rledger/report/statement"
|
5
|
+
|
6
|
+
require 'optparse'
|
7
|
+
require 'optparse/date'
|
8
|
+
|
9
|
+
module Rledger
|
10
|
+
class Runner
|
11
|
+
# parse command line
|
12
|
+
def self.parse(args)
|
13
|
+
options = Hash.new
|
14
|
+
|
15
|
+
opt_parser = OptionParser.new do |opts|
|
16
|
+
opts.banner = "Usage: rledger [options]"
|
17
|
+
|
18
|
+
opts.separator ""
|
19
|
+
opts.separator "Specific options:"
|
20
|
+
|
21
|
+
opts.on("-c", "--command STRING", String, "Command (one of check, statement, balance)") do |command|
|
22
|
+
options[:command] = command
|
23
|
+
end
|
24
|
+
|
25
|
+
opts.on("-v", "--voice VOICE", String, "Voice") do |voice|
|
26
|
+
options[:voice] = voice
|
27
|
+
end
|
28
|
+
|
29
|
+
#opts.on("-f", "--from_date DATE", Date, "From date") do |from_date|
|
30
|
+
# options[:from_date] = from_date
|
31
|
+
#end
|
32
|
+
|
33
|
+
#opts.on("-to", "--to_date DATE", Date, "To date") do |to_date|
|
34
|
+
# options[:to_date] = to_date
|
35
|
+
#end
|
36
|
+
|
37
|
+
opts.on( '-h', '--help', 'Display this screen' ) do
|
38
|
+
puts opts
|
39
|
+
puts <<EOF
|
40
|
+
Reads a ledger (http://www.ledger-cli.org) and performs simple operations.
|
41
|
+
|
42
|
+
Only the basic format is recognized: do not expect this gem to perform well
|
43
|
+
with virtual transactions. Different date formats and different currencies
|
44
|
+
should, however, be supported.
|
45
|
+
|
46
|
+
Example usages
|
47
|
+
|
48
|
+
rledger --command statement ledger.txt
|
49
|
+
rledger --command balance ledger.txt
|
50
|
+
|
51
|
+
EOF
|
52
|
+
exit
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
opt_parser.parse!(args)
|
57
|
+
options
|
58
|
+
end
|
59
|
+
|
60
|
+
# run: interpret command line and execute
|
61
|
+
def self.run(args)
|
62
|
+
# read options
|
63
|
+
options = parse(args)
|
64
|
+
|
65
|
+
# read data
|
66
|
+
data = []
|
67
|
+
ARGV.each do |argv|
|
68
|
+
data += Transaction.read(argv)
|
69
|
+
end
|
70
|
+
|
71
|
+
# now do
|
72
|
+
case options[:command]
|
73
|
+
when "check"
|
74
|
+
puts "This is what I understand from the input files:\n"
|
75
|
+
data.each do |transaction|
|
76
|
+
printf "%s\n", transaction.to_s
|
77
|
+
end
|
78
|
+
|
79
|
+
when "statement"
|
80
|
+
report = Statement.new(data)
|
81
|
+
report.compute(options[:voice])
|
82
|
+
puts "Statement of #{options[:voice]}"
|
83
|
+
puts report
|
84
|
+
|
85
|
+
when "balance"
|
86
|
+
report = Balance.new(data)
|
87
|
+
report.compute
|
88
|
+
puts report
|
89
|
+
|
90
|
+
else
|
91
|
+
puts "Oh, I wish I could understand what you mean."
|
92
|
+
puts "Try with --help."
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
data/rledger.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'rledger/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "rledger"
|
8
|
+
spec.version = Rledger::VERSION
|
9
|
+
spec.authors = ["Adolfo Villafiorita"]
|
10
|
+
spec.email = ["adolfo.villafiorita@me.com"]
|
11
|
+
spec.description = %q{CLI Ledger subset of http://www.ledger-cli.org}
|
12
|
+
spec.summary = %q{
|
13
|
+
Rledger reads ledgers in the [Ledger CLI](http://www.ledger-cli.org)
|
14
|
+
format and performs simple operations on them.
|
15
|
+
|
16
|
+
Only the basic format is recognized: **do not expect this gem to
|
17
|
+
perform well with complex ledger files.** The goal, in fact, is not
|
18
|
+
that of building a clone of [ledger](http://www.ledger-cli.org), but
|
19
|
+
being able to read simple ledger files in ruby.}
|
20
|
+
spec.homepage = "http://www.github.com/avillafiorita/rledger"
|
21
|
+
spec.license = "MIT"
|
22
|
+
|
23
|
+
spec.files = `git ls-files`.split($/)
|
24
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
25
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
26
|
+
spec.require_paths = ["lib"]
|
27
|
+
|
28
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
29
|
+
spec.add_development_dependency "rake"
|
30
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
|
3
|
+
require 'rledger/ledger/amount.rb'
|
4
|
+
|
5
|
+
class AmountTest < Test::Unit::TestCase
|
6
|
+
# def setup
|
7
|
+
# end
|
8
|
+
|
9
|
+
# def teardown
|
10
|
+
# end
|
11
|
+
|
12
|
+
def test_parse
|
13
|
+
a1 = Amount.new
|
14
|
+
a1.parse("")
|
15
|
+
result = (a1.amount == 0.00 and a1.currency == "")
|
16
|
+
assert result, "Derived entry not parsed"
|
17
|
+
|
18
|
+
a2 = Amount.new
|
19
|
+
a2.parse("100.00")
|
20
|
+
result = (a2.amount = 100.00 and a2.currency = "")
|
21
|
+
assert result, "Simple amount not parsed"
|
22
|
+
|
23
|
+
a3 = Amount.new
|
24
|
+
a3.parse("EUR 200.00")
|
25
|
+
result = (a3.amount = 200.00 and a1.currency = "EUR")
|
26
|
+
assert result, "Amount with currency not parsed."
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_derived
|
30
|
+
assert Amount.derived?(""), "Derived does not work."
|
31
|
+
assert not Amount.derived?("100.00"), "Derived does not work"
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_add!
|
35
|
+
a1 = Amount.new
|
36
|
+
a1.parse("EUR 100.00")
|
37
|
+
|
38
|
+
a2 = Amount.new
|
39
|
+
a2.parse("MTN 200.00")
|
40
|
+
|
41
|
+
a1.add!(a2)
|
42
|
+
assert a1.to_s == "EUR 100.00, MTN 200.00", "Adding different currencies does not work"
|
43
|
+
|
44
|
+
a3 = Amount.new
|
45
|
+
a3.parse("EUR 200.00")
|
46
|
+
|
47
|
+
a1.add!(a3)
|
48
|
+
assert a1.to_s == "EUR 300.00, MTN 200.00", "Adding different currencies does not work"
|
49
|
+
end
|
50
|
+
|
51
|
+
def test_multiply!
|
52
|
+
a1 = Amount.new
|
53
|
+
a1.parse("EUR 100.00")
|
54
|
+
a1.multiply(-2)
|
55
|
+
|
56
|
+
assert (a1.amount == -200.00 and a1.currency == "EUR"), "Multiply single amount does not work"
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
|
3
|
+
require 'rledger/ledger/post.rb'
|
4
|
+
|
5
|
+
class PostTest < Test::Unit::TestCase
|
6
|
+
# def setup
|
7
|
+
# end
|
8
|
+
|
9
|
+
# def teardown
|
10
|
+
# end
|
11
|
+
|
12
|
+
def test_parse
|
13
|
+
e1 = Post.new
|
14
|
+
e1.parse(" voice:voice 100.00 ; comment")
|
15
|
+
result =
|
16
|
+
e1.item == "voice:voice" and
|
17
|
+
e1.amount == "100.00" and
|
18
|
+
e1.derived == false and
|
19
|
+
e1.comment == "comment"
|
20
|
+
assert result, "Parsing e1 failed"
|
21
|
+
|
22
|
+
e2 = Post.new
|
23
|
+
e2.parse(" voice ; comment")
|
24
|
+
result =
|
25
|
+
e2.item == "voice" and
|
26
|
+
e2.comment == "comment" and
|
27
|
+
e2.derived == true
|
28
|
+
assert result, "Parsing e2 failed"
|
29
|
+
|
30
|
+
e3 = Post.new
|
31
|
+
e3.parse(" voice 100.00")
|
32
|
+
result =
|
33
|
+
e3.item == "voice" and
|
34
|
+
e3.amount = "100.00" and
|
35
|
+
e3.derived == false
|
36
|
+
assert result, "Parsing e3 failed"
|
37
|
+
|
38
|
+
e4 = Post.new
|
39
|
+
e4.parse(" voice 200.00 ; longer comment")
|
40
|
+
result =
|
41
|
+
e4.comment == "longer comment"
|
42
|
+
assert result, "Parsing e4 failed"
|
43
|
+
|
44
|
+
# e1.parse(" new_voice ; new comment")
|
45
|
+
# result =
|
46
|
+
# e1.item == "new_voice" and
|
47
|
+
# e1.amount == "" and
|
48
|
+
# e1.derived == true and
|
49
|
+
# e1.comment == "new comment"
|
50
|
+
# assert result, "Setting e1 with parsing failed"
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
|
3
|
+
require 'transactions/reconciliation.rb'
|
4
|
+
|
5
|
+
class ReconciliationTest < Test::Unit::TestCase
|
6
|
+
# def setup
|
7
|
+
# end
|
8
|
+
|
9
|
+
# def teardown
|
10
|
+
# end
|
11
|
+
|
12
|
+
def test_parse_and_set
|
13
|
+
r1 = Reconciliation.new
|
14
|
+
r1.parse("!")
|
15
|
+
assert r1.pending?
|
16
|
+
|
17
|
+
r2 = Reconciliation.new
|
18
|
+
r2.parse("?")
|
19
|
+
assert r2.discarded?
|
20
|
+
|
21
|
+
r3 = Reconciliation.new
|
22
|
+
r3.parse("*")
|
23
|
+
assert r3.reconciled!
|
24
|
+
|
25
|
+
r4 = Reconciliation.new
|
26
|
+
r4.parse("")
|
27
|
+
assert r4.unknown?
|
28
|
+
|
29
|
+
r2.pending!
|
30
|
+
assert r2.pending?
|
31
|
+
|
32
|
+
r2.reconciled!
|
33
|
+
assert r2.reconciled?
|
34
|
+
|
35
|
+
r2.discarded!
|
36
|
+
assert r2.discarded!
|
37
|
+
|
38
|
+
r2.unknown!
|
39
|
+
assert r2.unknown?
|
40
|
+
|
41
|
+
error = Reconciliation.new
|
42
|
+
error.parse("a")
|
43
|
+
assert error.unknown!
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
|
3
|
+
require 'Date'
|
4
|
+
require 'rledger/ledger/transaction'
|
5
|
+
|
6
|
+
class TransactionTest < Test::Unit::TestCase
|
7
|
+
def test_tbase_parse
|
8
|
+
tb = TransactionBase.new
|
9
|
+
tb.parse "15/02/2010 ! (ID) Payee long"
|
10
|
+
result = tb.date == Date.civil(2010, 02, 15) and
|
11
|
+
tb.id == "ID" and
|
12
|
+
tb.reconciliation.pending! and
|
13
|
+
tb.payee = "Payee long"
|
14
|
+
assert result
|
15
|
+
|
16
|
+
tb = TransactionBase.new
|
17
|
+
tb.parse "15/02/2008 (ID) Payee long"
|
18
|
+
result = tb.date == Date.civil(2008, 02, 15) and
|
19
|
+
tb.id == "ID" and
|
20
|
+
tb.reconciliation.unknown? and
|
21
|
+
tb.payee = "Payee long"
|
22
|
+
assert result
|
23
|
+
|
24
|
+
tb = TransactionBase.new
|
25
|
+
tb.parse "15/06/2010 * Payee very long"
|
26
|
+
result = tb.date == Date.civil(2010, 06, 15) and
|
27
|
+
tb.id == "" and
|
28
|
+
tb.reconciliation.reconciled? and
|
29
|
+
tb.payee = "Payee very long"
|
30
|
+
assert result
|
31
|
+
|
32
|
+
tb = TransactionBase.new
|
33
|
+
tb.parse "15/02/2010 Payee very long"
|
34
|
+
result = tb.date == Date.civil(2010, 02, 15) and
|
35
|
+
tb.id == "" and
|
36
|
+
tb.reconciliation.unknown? and
|
37
|
+
tb.payee = "Payee very long"
|
38
|
+
assert result
|
39
|
+
|
40
|
+
tb = TransactionBase.new
|
41
|
+
tb.parse "15/02/2010"
|
42
|
+
result = tb.date == Date.civil(2010, 02, 15) and
|
43
|
+
tb.id == "" and
|
44
|
+
tb.reconciliation.unknown? and
|
45
|
+
tb.payee = ""
|
46
|
+
assert result
|
47
|
+
end
|
48
|
+
|
49
|
+
def test_t_parse
|
50
|
+
t = Transaction.new
|
51
|
+
t.parse("15/02/2010 ! (ID) Payee long\n voice 10.00 ; comment\n other_voice 20.00 ; other comment")
|
52
|
+
result = t.date == Date.civil(2010, 02, 15) and
|
53
|
+
t.id == "ID" and
|
54
|
+
t.reconciliation.pending! and
|
55
|
+
t.payee = "Payee long" and
|
56
|
+
t.entries.size == 2
|
57
|
+
assert result
|
58
|
+
|
59
|
+
post = t.posts[0]
|
60
|
+
result = post.item == "voice" and post.amount == "10.00" and post.comment == "comment"
|
61
|
+
assert result
|
62
|
+
|
63
|
+
post = t.posts[1]
|
64
|
+
result = post.item == "other_voice" and post.amount == "20.00" and post.comment == "other comment"
|
65
|
+
assert result
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
metadata
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rledger
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Adolfo Villafiorita
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-10-22 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ~>
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.3'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ~>
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
description: CLI Ledger subset of http://www.ledger-cli.org
|
42
|
+
email:
|
43
|
+
- adolfo.villafiorita@me.com
|
44
|
+
executables:
|
45
|
+
- rledger
|
46
|
+
extensions: []
|
47
|
+
extra_rdoc_files: []
|
48
|
+
files:
|
49
|
+
- .gitignore
|
50
|
+
- Gemfile
|
51
|
+
- LICENSE.txt
|
52
|
+
- README.md
|
53
|
+
- Rakefile
|
54
|
+
- bin/rledger
|
55
|
+
- lib/rledger.rb
|
56
|
+
- lib/rledger/ledger/amount.rb
|
57
|
+
- lib/rledger/ledger/post.rb
|
58
|
+
- lib/rledger/ledger/reconciliation.rb
|
59
|
+
- lib/rledger/ledger/transaction.rb
|
60
|
+
- lib/rledger/report/balance.rb
|
61
|
+
- lib/rledger/report/statement.rb
|
62
|
+
- lib/rledger/version.rb
|
63
|
+
- rledger.gemspec
|
64
|
+
- test/rledger/amount_test.rb
|
65
|
+
- test/rledger/post_test.rb
|
66
|
+
- test/rledger/reconciliation_test.rb
|
67
|
+
- test/rledger/transaction_test.rb
|
68
|
+
homepage: http://www.github.com/avillafiorita/rledger
|
69
|
+
licenses:
|
70
|
+
- MIT
|
71
|
+
metadata: {}
|
72
|
+
post_install_message:
|
73
|
+
rdoc_options: []
|
74
|
+
require_paths:
|
75
|
+
- lib
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
77
|
+
requirements:
|
78
|
+
- - '>='
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: '0'
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
requirements: []
|
87
|
+
rubyforge_project:
|
88
|
+
rubygems_version: 2.0.3
|
89
|
+
signing_key:
|
90
|
+
specification_version: 4
|
91
|
+
summary: 'Rledger reads ledgers in the [Ledger CLI](http://www.ledger-cli.org) format
|
92
|
+
and performs simple operations on them. Only the basic format is recognized: **do
|
93
|
+
not expect this gem to perform well with complex ledger files.** The goal, in fact,
|
94
|
+
is not that of building a clone of [ledger](http://www.ledger-cli.org), but being
|
95
|
+
able to read simple ledger files in ruby.'
|
96
|
+
test_files:
|
97
|
+
- test/rledger/amount_test.rb
|
98
|
+
- test/rledger/post_test.rb
|
99
|
+
- test/rledger/reconciliation_test.rb
|
100
|
+
- test/rledger/transaction_test.rb
|