gnucash 1.4.0 → 1.6.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 52713dfe700ed99ecbde5b43d7c0614e86d3ec34
4
- data.tar.gz: d59a7c2b7069bc1c355201e4c4e9f4ec832d1d55
2
+ SHA256:
3
+ metadata.gz: 72eb52f6d34ca1767398d5a0bbb0379b311ef87ed8ca7ae9190d7c02b5692e0d
4
+ data.tar.gz: 8f414c48579e293d9539b06c90e79e7a13fba80ffd53f8f07d483e2ee8cfcc1a
5
5
  SHA512:
6
- metadata.gz: ed54f96c4c23bb1960895f8c3df8918faed0b83362e73b5682251501fd8b664b3458797fe0a9abd45d5fb378b81f1dc508c825ff83c2e3b9a1a376c5c1a94e0c
7
- data.tar.gz: a92fc8a0fcb6eff1a5e851f9e512fd8c7714aaec0d69053055a879a7548e1036a283ba7cde903c34f52d615dbd2e80076ceb3a532fc840541f293a9823bea4a2
6
+ metadata.gz: ad9bf95fcff49be8da8027e4c15a6246b261c4855e752073be128c883a1511d960791a51d80f4c084f49216d7242fcebf5c25505b4fa502807d1222458b2ad19
7
+ data.tar.gz: 77b49d4963895f3b08a7f8e55b11d5e3e427e3fa5425a80384fe09aad80fb09d17ab0f877b47fbb63ceb09c740d6e56b27719a057afb6e587829a072f74519e4
data/.gitignore CHANGED
@@ -17,3 +17,4 @@ tmp
17
17
  test.rb
18
18
  spec/books/*.gnucash.*.*
19
19
  spec/books/*.LCK
20
+ bin/
data/.travis.yml ADDED
@@ -0,0 +1,26 @@
1
+ language: ruby
2
+
3
+ bundler_args: --without debugger
4
+
5
+ cache: bundler
6
+ sudo: false
7
+
8
+ before_install:
9
+ - gem --version
10
+
11
+ script: bundle exec rspec
12
+
13
+ rvm:
14
+ - 2.4.6
15
+ - 2.5.5
16
+ - 2.6.4
17
+ - ruby-head
18
+
19
+ matrix:
20
+ allow_failures:
21
+ - rvm: ruby-head
22
+
23
+ notifications:
24
+ email:
25
+ recipients:
26
+ - niklaus.giger@member.fsf.org
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # ChangeLog
2
2
 
3
+ ## v1.6.0
4
+
5
+ - add Customer class and Book#find_customer_by_full_name (#12, #13)
6
+
7
+ ## v1.5.0
8
+
9
+ - add options Hash to Account#balance_on with :recursive option
10
+
3
11
  ## v1.4.0
4
12
 
5
13
  - add Account#parent_id and #parent
data/Gemfile CHANGED
@@ -2,3 +2,8 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in gnucash.gemspec
4
4
  gemspec
5
+
6
+ group :development do
7
+ # rdbg / Cursor: depuración (vscode-rdbg)
8
+ gem "debug", ">= 1.9", require: false
9
+ end
data/Gemfile.lock CHANGED
@@ -1,44 +1,96 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gnucash (1.4.0)
4
+ gnucash (1.6.0)
5
5
  nokogiri
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
- diff-lcs (1.3)
11
- docile (1.1.5)
12
- json (2.1.0)
13
- mini_portile2 (2.3.0)
14
- nokogiri (1.8.1)
15
- mini_portile2 (~> 2.3.0)
16
- rake (12.3.0)
17
- rdoc (6.0.1)
18
- rspec (3.7.0)
19
- rspec-core (~> 3.7.0)
20
- rspec-expectations (~> 3.7.0)
21
- rspec-mocks (~> 3.7.0)
22
- rspec-core (3.7.1)
23
- rspec-support (~> 3.7.0)
24
- rspec-expectations (3.7.0)
10
+ date (3.5.1)
11
+ debug (1.11.1)
12
+ irb (~> 1.10)
13
+ reline (>= 0.3.8)
14
+ diff-lcs (1.6.2)
15
+ docile (1.4.1)
16
+ erb (6.0.4)
17
+ io-console (0.8.2)
18
+ irb (1.18.0)
19
+ pp (>= 0.6.0)
20
+ prism (>= 1.3.0)
21
+ rdoc (>= 4.0.0)
22
+ reline (>= 0.4.2)
23
+ mini_portile2 (2.8.9)
24
+ nokogiri (1.19.3)
25
+ mini_portile2 (~> 2.8.2)
26
+ racc (~> 1.4)
27
+ nokogiri (1.19.3-aarch64-linux-gnu)
28
+ racc (~> 1.4)
29
+ nokogiri (1.19.3-aarch64-linux-musl)
30
+ racc (~> 1.4)
31
+ nokogiri (1.19.3-arm-linux-gnu)
32
+ racc (~> 1.4)
33
+ nokogiri (1.19.3-arm-linux-musl)
34
+ racc (~> 1.4)
35
+ nokogiri (1.19.3-arm64-darwin)
36
+ racc (~> 1.4)
37
+ nokogiri (1.19.3-x86_64-darwin)
38
+ racc (~> 1.4)
39
+ nokogiri (1.19.3-x86_64-linux-gnu)
40
+ racc (~> 1.4)
41
+ nokogiri (1.19.3-x86_64-linux-musl)
42
+ racc (~> 1.4)
43
+ pp (0.6.3)
44
+ prettyprint
45
+ prettyprint (0.2.0)
46
+ prism (1.9.0)
47
+ psych (5.3.1)
48
+ date
49
+ stringio
50
+ racc (1.8.1)
51
+ rake (13.4.2)
52
+ rdoc (7.2.0)
53
+ erb
54
+ psych (>= 4.0.0)
55
+ tsort
56
+ reline (0.6.3)
57
+ io-console (~> 0.5)
58
+ rspec (3.13.2)
59
+ rspec-core (~> 3.13.0)
60
+ rspec-expectations (~> 3.13.0)
61
+ rspec-mocks (~> 3.13.0)
62
+ rspec-core (3.13.6)
63
+ rspec-support (~> 3.13.0)
64
+ rspec-expectations (3.13.5)
25
65
  diff-lcs (>= 1.2.0, < 2.0)
26
- rspec-support (~> 3.7.0)
27
- rspec-mocks (3.7.0)
66
+ rspec-support (~> 3.13.0)
67
+ rspec-mocks (3.13.8)
28
68
  diff-lcs (>= 1.2.0, < 2.0)
29
- rspec-support (~> 3.7.0)
30
- rspec-support (3.7.0)
31
- simplecov (0.15.1)
32
- docile (~> 1.1.0)
33
- json (>= 1.8, < 3)
34
- simplecov-html (~> 0.10.0)
35
- simplecov-html (0.10.2)
36
- yard (0.9.12)
69
+ rspec-support (~> 3.13.0)
70
+ rspec-support (3.13.7)
71
+ simplecov (0.22.0)
72
+ docile (~> 1.1)
73
+ simplecov-html (~> 0.11)
74
+ simplecov_json_formatter (~> 0.1)
75
+ simplecov-html (0.13.2)
76
+ simplecov_json_formatter (0.1.4)
77
+ stringio (3.2.0)
78
+ tsort (0.2.0)
79
+ yard (0.9.43)
37
80
 
38
81
  PLATFORMS
82
+ aarch64-linux-gnu
83
+ aarch64-linux-musl
84
+ arm-linux-gnu
85
+ arm-linux-musl
86
+ arm64-darwin
39
87
  ruby
88
+ x86_64-darwin
89
+ x86_64-linux-gnu
90
+ x86_64-linux-musl
40
91
 
41
92
  DEPENDENCIES
93
+ debug (>= 1.9)
42
94
  gnucash!
43
95
  rake
44
96
  rdoc
@@ -47,4 +99,4 @@ DEPENDENCIES
47
99
  yard
48
100
 
49
101
  BUNDLED WITH
50
- 1.16.1
102
+ 4.0.11
data/README.md CHANGED
@@ -37,7 +37,7 @@ act.transactions.each do |txn|
37
37
  txn.date,
38
38
  txn.value,
39
39
  balance,
40
- txn.description))
40
+ txn.description)
41
41
  end
42
42
 
43
43
  year = Date.today.year
@@ -45,6 +45,14 @@ delta = act.balance_on("#{year}-12-31") - act.balance_on("#{year - 1}-12-31")
45
45
  puts "You've saved #{delta} this year so far!"
46
46
  ```
47
47
 
48
+ To get the balance of an account, use ```act.balance_on("#{year}-12-31")```.
49
+ To get the total balance of an account with all its children accounts,
50
+ use ```act.balance_on("#{year}-12-31", recursive: true)```.
51
+
52
+ ## Full YARD Documentation
53
+
54
+ See <https://rubydoc.info/github/holtrop/ruby-gnucash/master>.
55
+
48
56
  ## Contributing
49
57
 
50
58
  1. Fork it
data/gnucash.gemspec CHANGED
@@ -13,7 +13,7 @@ Gem::Specification.new do |gem|
13
13
  gem.homepage = "https://github.com/holtrop/ruby-gnucash"
14
14
  gem.license = "MIT"
15
15
 
16
- gem.files = `git ls-files`.split($/)
16
+ gem.files = `git ls-files`.split($/).reject { |f| f == "bin/rdbg" }
17
17
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
18
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
19
  gem.require_paths = ["lib"]
@@ -15,6 +15,11 @@ module Gnucash
15
15
  # @return [String] The GUID of the account.
16
16
  attr_reader :id
17
17
 
18
+ # @return [String, nil] Account code (+act:code+), if set in GnuCash.
19
+ #
20
+ # @since 1.6.0
21
+ attr_reader :code
22
+
18
23
  # @return [Array<AccountTransaction>]
19
24
  # List of transactions associated with this account.
20
25
  attr_reader :transactions
@@ -22,6 +27,16 @@ module Gnucash
22
27
  # @return [Boolean] Whether the account is a placeholder or not.
23
28
  attr_reader :placeholder
24
29
 
30
+ # @return [String, nil] Commodity namespace (+cmdty:space+) from +act:commodity+, if present.
31
+ #
32
+ # @since 1.6.0
33
+ attr_reader :commodity_space
34
+
35
+ # @return [String, nil] Commodity id (+cmdty:id+) from +act:commodity+, if present.
36
+ #
37
+ # @since 1.6.0
38
+ attr_reader :commodity_id
39
+
25
40
  # @since 1.4.0
26
41
  #
27
42
  # @return [String, nil] The GUID of the parent account, if any.
@@ -38,6 +53,8 @@ module Gnucash
38
53
  @type = node.xpath('act:type').text
39
54
  @description = node.xpath('act:description').text
40
55
  @id = node.xpath('act:id').text
56
+ code_raw = node.at_xpath('act:code')&.text
57
+ @code = (code_raw.nil? || code_raw.empty?) ? nil : code_raw
41
58
  @parent_id = node.xpath('act:parent').text
42
59
  @parent_id = nil if @parent_id == ""
43
60
  @transactions = []
@@ -46,6 +63,29 @@ module Gnucash
46
63
  (slot.xpath("slot:key").first.text == "placeholder" and
47
64
  slot.xpath("slot:value").first.text == "true")
48
65
  end ? true : false
66
+
67
+ cmd = node.at_xpath("act:commodity")
68
+ if cmd
69
+ @commodity_space = cmd.at_xpath("cmdty:space")&.text&.strip
70
+ @commodity_id = cmd.at_xpath("cmdty:id")&.text&.strip
71
+ @commodity_space = nil if @commodity_space.nil? || @commodity_space.empty?
72
+ @commodity_id = nil if @commodity_id.nil? || @commodity_id.empty?
73
+ else
74
+ @commodity_space = nil
75
+ @commodity_id = nil
76
+ end
77
+ end
78
+
79
+ # Priced {Security} for this account's commodity, if the commodity appears in the
80
+ # book's price database (+gnc:pricedb+). Otherwise +nil+.
81
+ #
82
+ # @since 1.6.0
83
+ #
84
+ # @return [Security, nil]
85
+ def security
86
+ return nil unless @commodity_space && @commodity_id
87
+
88
+ @book.find_security(@commodity_space, @commodity_id)
49
89
  end
50
90
 
51
91
  # Return the fully qualified account name.
@@ -99,13 +139,28 @@ module Gnucash
99
139
  # Transactions that occur on the given date are included in the returned
100
140
  # balance.
101
141
  #
102
- # @param date [String, Date] Date on which to query the balance.
142
+ # @param date [String, Date]
143
+ # Date on which to query the balance.
144
+ # @param options [Hash]
145
+ # Optional parameters.
146
+ # @option options [Boolean] :recursive
147
+ # Whether to include children account balances.
103
148
  #
104
- # @return [Value] Balance of the account as of the date given.
105
- def balance_on(date)
149
+ # @return [Value]
150
+ # Balance of the account as of the date given.
151
+ def balance_on(date, options = {})
106
152
  date = Date.parse(date) if date.is_a?(String)
107
- return Value.new(0) unless @balances.size > 0
108
- return Value.new(0) if @balances.first[:date] > date
153
+ return_value = Value.new(0)
154
+
155
+ if options[:recursive]
156
+ # Get all child accounts from this account and accumulate the balances of them.
157
+ @book.accounts.reject { |account| account.parent != self }.each do |child_account|
158
+ return_value += child_account.balance_on(date, recursive: true)
159
+ end
160
+ end
161
+
162
+ return return_value unless @balances.size > 0
163
+ return return_value if @balances.first[:date] > date
109
164
  return @balances.last[:value] if date >= @balances.last[:date]
110
165
  imin = 0
111
166
  imax = @balances.size - 2
@@ -126,7 +181,7 @@ module Gnucash
126
181
  # @return [Array<Symbol>] Attributes used to build the inspection string
127
182
  # @see Gnucash::Support::LightInspect
128
183
  def attributes
129
- %i[id name description type placeholder parent_id]
184
+ %i[id name description type code placeholder parent_id commodity_space commodity_id]
130
185
  end
131
186
 
132
187
  private
data/lib/gnucash/book.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require "date"
1
2
  require "zlib"
2
3
  require "nokogiri"
3
4
 
@@ -6,12 +7,27 @@ module Gnucash
6
7
  class Book
7
8
  include Support::LightInspect
8
9
 
10
+ # One row from +gnc:pricedb+.
11
+ #
12
+ # @since 1.6.0
13
+ PriceRow = Struct.new(:commodity_space, :commodity_id, :currency_space, :currency_id, :date, :value)
14
+
9
15
  # @return [Array<Account>] Accounts in the book.
10
16
  attr_reader :accounts
11
17
 
18
+ # @return [Array<Account>] Customers in the book.
19
+ # @since 1.6.0
20
+ attr_reader :customers
21
+
12
22
  # @return [Array<Transaction>] Transactions in the book.
13
23
  attr_reader :transactions
14
24
 
25
+ # @return [Array<PriceRow>]
26
+ # Raw price-database rows (commodity/currency/value/date). Prefer
27
+ # {Security#value_on} for valuations.
28
+ # @since 1.6.0
29
+ attr_reader :price_rows
30
+
15
31
  # @return [Date] Date of the first transaction in the book.
16
32
  attr_reader :start_date
17
33
 
@@ -36,8 +52,11 @@ module Gnucash
36
52
  raise "Error: Expected to find one gnc:book entry"
37
53
  end
38
54
  @book_node = book_nodes.first
55
+ build_customers
39
56
  build_accounts
40
57
  build_transactions
58
+ build_price_quotes
59
+ build_commodity_isin_index
41
60
  finalize
42
61
  end
43
62
 
@@ -61,6 +80,76 @@ module Gnucash
61
80
  @accounts.find { |a| a.full_name == full_name }
62
81
  end
63
82
 
83
+ # Return a handle to the Customer object that has the given fully-qualified
84
+ # name.
85
+ #
86
+ # @since 1.6.0
87
+ #
88
+ # @param full_name [String]
89
+ # Fully-qualified customer name (ex: "Joe Doe").
90
+ #
91
+ # @return [Customer, nil] Customer object, or nil if not found.
92
+ def find_customer_by_full_name(full_name)
93
+ @customers.find { |a| a.full_name == full_name }
94
+ end
95
+
96
+ # Return every {Security} that appears in the price database (unique commodity).
97
+ #
98
+ # @since 1.6.0
99
+ #
100
+ # @return [Array<Security>]
101
+ def securities
102
+ @securities ||= @price_rows.map { |r| [r.commodity_space, r.commodity_id] }.uniq.map do |space, id|
103
+ Security.new(self, space, id)
104
+ end
105
+ end
106
+
107
+ # Look up a security by GnuCash commodity +space+ and +id+.
108
+ #
109
+ # @since 1.6.0
110
+ #
111
+ # @return [Security, nil]
112
+ def find_security(space, id)
113
+ return nil unless @price_rows.any? { |r| r.commodity_space == space && r.commodity_id == id }
114
+ Security.new(self, space, id)
115
+ end
116
+
117
+ # Look up a priced security whose commodity defines this ISIN (+cmdty:xcode+ or a slot
118
+ # whose key matches +isin+, e.g. +user:ISIN+). Comparison ignores spaces, hyphens and case.
119
+ #
120
+ # @since 1.6.0
121
+ #
122
+ # @param isin [String] ISIN as stored or typed (e.g. +"US0378331005"+).
123
+ #
124
+ # @return [Security, nil]
125
+ def find_security_by_isin(isin)
126
+ key = ISIN.normalize(isin)
127
+ return nil if key.empty?
128
+
129
+ pair = @isin_index[key]
130
+ return nil unless pair
131
+
132
+ find_security(pair[0], pair[1])
133
+ end
134
+
135
+ # ISIN for a commodity if present in the book (+nil+ otherwise).
136
+ #
137
+ # @since 1.6.0
138
+ #
139
+ # @return [String, nil] Normalized ISIN (12 uppercase alphanumeric characters).
140
+ def isin_for_commodity(space, id)
141
+ @isin_for_commodity[[space, id]]
142
+ end
143
+
144
+ # Price-database rows for one commodity (used by {Security#value_on}).
145
+ #
146
+ # @since 1.6.0
147
+ #
148
+ # @return [Array<PriceRow>]
149
+ def quotes_for_commodity(space, id)
150
+ @price_rows.select { |r| r.commodity_space == space && r.commodity_id == id }
151
+ end
152
+
64
153
  # Attributes available for inspection
65
154
  #
66
155
  # @return [Array<Symbol>] Attributes used to build the inspection string
@@ -78,6 +167,12 @@ module Gnucash
78
167
  end
79
168
  end
80
169
 
170
+ def build_customers
171
+ @customers = @book_node.xpath('gnc:GncCustomer').map do |customer_node|
172
+ Customer.new(self, customer_node)
173
+ end
174
+ end
175
+
81
176
  # @return [void]
82
177
  def build_transactions
83
178
  @start_date = nil
@@ -90,6 +185,82 @@ module Gnucash
90
185
  end
91
186
  end
92
187
 
188
+ # @return [void]
189
+ def build_price_quotes
190
+ pricedb = @book_node.at_xpath('gnc:pricedb')
191
+ @price_rows = []
192
+ return unless pricedb
193
+
194
+ pricedb.element_children.each do |node|
195
+ next unless node.element?
196
+ row = parse_price_node(node)
197
+ @price_rows << row if row
198
+ end
199
+ end
200
+
201
+ # @return [PriceRow, nil]
202
+ def parse_price_node(node)
203
+ return nil unless node.at_xpath('price:commodity')
204
+
205
+ cmd = node.at_xpath('price:commodity')
206
+ cur = node.at_xpath('price:currency')
207
+ return nil unless cmd && cur
208
+
209
+ commodity_space = cmd.at_xpath('cmdty:space')&.text
210
+ commodity_id = cmd.at_xpath('cmdty:id')&.text
211
+ currency_space = cur.at_xpath('cmdty:space')&.text
212
+ currency_id = cur.at_xpath('cmdty:id')&.text
213
+ return nil if [commodity_space, commodity_id, currency_space, currency_id].any? { |s| s.nil? || s.empty? }
214
+
215
+ ts = node.at_xpath('price:time/ts:date')&.text
216
+ return nil if ts.nil? || ts.empty?
217
+
218
+ date = Date.parse(ts.split(' ').first)
219
+ val_text = node.at_xpath('price:value')&.text
220
+ return nil if val_text.nil? || val_text.empty?
221
+
222
+ PriceRow.new(commodity_space, commodity_id, currency_space, currency_id, date, Value.new(val_text))
223
+ end
224
+
225
+ # @return [void]
226
+ def build_commodity_isin_index
227
+ @isin_for_commodity = {}
228
+ @isin_index = {}
229
+
230
+ @book_node.xpath("gnc:commodity").each do |node|
231
+ space = node.at_xpath("cmdty:space")&.text&.strip
232
+ id = node.at_xpath("cmdty:id")&.text&.strip
233
+ next if space.nil? || id.nil? || space.empty? || id.empty?
234
+
235
+ raw = extract_isin_from_commodity_node(node)
236
+ next unless raw
237
+
238
+ key = ISIN.normalize(raw)
239
+ next unless ISIN.valid_format?(key)
240
+
241
+ @isin_for_commodity[[space, id]] = key
242
+ @isin_index[key] ||= [space, id]
243
+ end
244
+ end
245
+
246
+ # @return [String, nil] raw ISIN string from XML before normalization
247
+ def extract_isin_from_commodity_node(node)
248
+ node.xpath(".//slot").each do |slot|
249
+ k = slot.at_xpath("slot:key")&.text
250
+ next unless k&.match?(/isin/i)
251
+
252
+ v = slot.at_xpath("slot:value")&.text&.strip
253
+ next if v.nil? || v.empty?
254
+
255
+ return v if ISIN.valid_format?(v)
256
+ end
257
+
258
+ xcode = node.at_xpath("cmdty:xcode")&.text&.strip
259
+ return xcode if xcode && ISIN.valid_format?(xcode)
260
+
261
+ nil
262
+ end
263
+
93
264
  # @return [void]
94
265
  def finalize
95
266
  @accounts.sort! do |a, b|
@@ -0,0 +1,54 @@
1
+ module Gnucash
2
+ # Represent a GnuCash customer object.
3
+ # @since 1.6.0
4
+ class Customer
5
+ include Support::LightInspect
6
+ # gnc:GncCustomer
7
+ ## id, company, name, addr1, addr2, addr3, addr4, phone, fax, email, notes, shipname, shipaddr1, shipaddr2, shipaddr3, shipaddr4, shiphone, shipfax, shipmail
8
+
9
+ # @return [String] The name of the customer (unqualified).
10
+ attr_reader :name
11
+
12
+ # @return [String] The GUID of the customer.
13
+ attr_reader :guid
14
+
15
+ # @return [String] The ID of the customer.
16
+ attr_reader :id
17
+
18
+ # @return [String] The address of the customer.
19
+ attr_reader :address
20
+
21
+ # @return [String] The shipping address of the customer.
22
+ attr_reader :shipping_address
23
+
24
+ # Create an customer object.
25
+ #
26
+ # @param book [Book] The {Gnucash::Book} containing the customer.
27
+ # @param node [Nokogiri::XML::Node] Nokogiri XML node.
28
+ def initialize(book, node)
29
+ @book = book
30
+ @node = node
31
+ @name = node.xpath('cust:name').text
32
+ @id = node.xpath('cust:id').text
33
+ @guid = node.xpath('cust:guid').text
34
+ @address = node.xpath('cust:addr').text
35
+ @shipping_address = node.xpath('cust:shipaddr').text
36
+ end
37
+
38
+ # Return the fully qualified customer name.
39
+ #
40
+ # @return [String] Fully qualified customer name.
41
+ def full_name
42
+ name
43
+ end
44
+
45
+ # Attributes available for inspection
46
+ #
47
+ # @return [Array<Symbol>] Attributes used to build the inspection string
48
+ # @see Gnucash::Support::LightInspect
49
+ def attributes
50
+ %i[id name guid address shipping_address]
51
+ end
52
+
53
+ end
54
+ end
@@ -0,0 +1,25 @@
1
+ module Gnucash
2
+ # Helpers for ISIN (ISO 6166) strings as stored on GnuCash commodities.
3
+ #
4
+ # @since 1.6.0
5
+ module ISIN
6
+ module_function
7
+
8
+ # @param str [String, nil]
9
+ # @return [String] Uppercase ISIN with spaces and hyphens removed.
10
+ def normalize(str)
11
+ return "" if str.nil? || str.to_s.empty?
12
+ str.to_s.gsub(/[\s-]/, "").upcase
13
+ end
14
+
15
+ # Rough format check: 12 alphanumeric characters after normalization.
16
+ #
17
+ # @param str [String, nil]
18
+ # @return [Boolean]
19
+ def valid_format?(str)
20
+ s = normalize(str)
21
+ return false if s.length != 12
22
+ s.match?(/\A[A-Z0-9]{12}\z/)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,116 @@
1
+ require "date"
2
+
3
+ module Gnucash
4
+ # Price quote for a security (commodity) from the GnuCash price database: the
5
+ # value of one unit of the security expressed in +currency+ as of +date+.
6
+ #
7
+ # @since 1.6.0
8
+ class SecurityQuote
9
+ include Support::LightInspect
10
+
11
+ # @return [Value] Price of one unit of the security in the quote currency.
12
+ attr_reader :value
13
+
14
+ # @return [String] Commodity namespace (GnuCash "space") of the quote currency.
15
+ attr_reader :currency_space
16
+
17
+ # @return [String] Commodity id of the quote currency (e.g. +"USD"+).
18
+ attr_reader :currency_id
19
+
20
+ # @return [Date] Date of the price in the book (quote time, date-only).
21
+ attr_reader :date
22
+
23
+ def initialize(value:, currency_space:, currency_id:, date:)
24
+ @value = value
25
+ @currency_space = currency_space
26
+ @currency_id = currency_id
27
+ @date = date
28
+ end
29
+
30
+ def attributes
31
+ %i[value currency_space currency_id date]
32
+ end
33
+ end
34
+
35
+ # A security (stock, fund, etc.) identified by its GnuCash commodity
36
+ # +space+ and +id+, with prices loaded from the book's price database.
37
+ #
38
+ # @since 1.6.0
39
+ class Security
40
+ include Support::LightInspect
41
+
42
+ # @return [String] Commodity namespace (e.g. +"NASDAQ"+, +"ISO4217"+).
43
+ attr_reader :space
44
+
45
+ # @return [String] Commodity id (e.g. ticker or currency code).
46
+ attr_reader :id
47
+
48
+ # @param book [Book] Parent book.
49
+ # @param space [String] Commodity space.
50
+ # @param id [String] Commodity id.
51
+ def initialize(book, space, id)
52
+ @book = book
53
+ @space = space
54
+ @id = id
55
+ end
56
+
57
+ # @return [String, nil] ISIN from the commodity definition (+cmdty:xcode+ or ISIN slot), normalized.
58
+ def isin
59
+ @book.isin_for_commodity(@space, @id)
60
+ end
61
+
62
+ # Return the most recent price quote whose date is on or before the given
63
+ # valuation date.
64
+ #
65
+ # If the security has multiple quote currencies, +currency_space+ and
66
+ # +currency_id+ select one; if omitted, USD (+ISO4217+ / +USD+) is preferred
67
+ # when present, otherwise an arbitrary quote chain is used.
68
+ #
69
+ # @param date [String, Date] Valuation date.
70
+ # @param currency_space [String, nil] Restrict to this quote currency space.
71
+ # @param currency_id [String, nil] Restrict to this quote currency id.
72
+ #
73
+ # @return [SecurityQuote, nil] Quote used for valuation, or nil if none applies.
74
+ def value_on(date, currency_space: nil, currency_id: nil)
75
+ date = Date.parse(date) if date.is_a?(String)
76
+ if (currency_space.nil? ^ currency_id.nil?)
77
+ raise ArgumentError, "currency_space and currency_id must both be set or both omitted"
78
+ end
79
+
80
+ quotes = @book.quotes_for_commodity(@space, @id)
81
+ return nil if quotes.empty?
82
+
83
+ filtered =
84
+ if currency_space
85
+ quotes.select { |q| q.currency_space == currency_space && q.currency_id == currency_id }
86
+ else
87
+ quotes
88
+ end
89
+ return nil if filtered.empty?
90
+
91
+ pick_currency = lambda do |list|
92
+ usd = list.select { |q| q.currency_space == "ISO4217" && q.currency_id == "USD" }
93
+ (usd.empty? ? list : usd)
94
+ end
95
+
96
+ candidates = currency_space ? filtered : pick_currency.call(filtered)
97
+ return nil if candidates.empty?
98
+
99
+ candidates = candidates.select { |q| q.date <= date }
100
+ return nil if candidates.empty?
101
+
102
+ best = candidates.max_by(&:date)
103
+
104
+ SecurityQuote.new(
105
+ value: best.value,
106
+ currency_space: best.currency_space,
107
+ currency_id: best.currency_id,
108
+ date: best.date
109
+ )
110
+ end
111
+
112
+ def attributes
113
+ %i[space id isin]
114
+ end
115
+ end
116
+ end
@@ -1,4 +1,4 @@
1
1
  module Gnucash
2
2
  # gem version
3
- VERSION = "1.4.0"
3
+ VERSION = "1.6.0"
4
4
  end
data/lib/gnucash.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  require_relative "gnucash/support"
2
2
  require_relative "gnucash/account"
3
+ require_relative "gnucash/customer"
3
4
  require_relative "gnucash/account_transaction"
5
+ require_relative "gnucash/isin"
6
+ require_relative "gnucash/security"
4
7
  require_relative "gnucash/book"
5
8
  require_relative "gnucash/transaction"
6
9
  require_relative "gnucash/value"
Binary file
@@ -33,9 +33,10 @@
33
33
  <gnc:book version="2.0.0">
34
34
  <book:id type="guid">b8178c8d763ec49988c2696d9e41d7f6</book:id>
35
35
  <gnc:count-data cd:type="commodity">1</gnc:count-data>
36
- <gnc:count-data cd:type="account">64</gnc:count-data>
37
- <gnc:count-data cd:type="transaction">473</gnc:count-data>
36
+ <gnc:count-data cd:type="account">65</gnc:count-data>
37
+ <gnc:count-data cd:type="transaction">474</gnc:count-data>
38
38
  <gnc:count-data cd:type="schedxaction">6</gnc:count-data>
39
+ <gnc:count-data cd:type="gnc:GncCustomer">1</gnc:count-data>
39
40
  <gnc:commodity version="2.0.0">
40
41
  <cmdty:space>ISO4217</cmdty:space>
41
42
  <cmdty:id>USD</cmdty:id>
@@ -30238,6 +30239,56 @@
30238
30239
  </gnc:recurrence>
30239
30240
  </sx:schedule>
30240
30241
  </gnc:schedxaction>
30242
+ <gnc:GncCustomer version="2.0.0">
30243
+ <cust:guid type="guid">0938eaff7545d7ba45fe7b866d60a209</cust:guid>
30244
+ <cust:name>Joe Doe</cust:name>
30245
+ <cust:id>8765</cust:id>
30246
+ <cust:addr version="2.0.0">
30247
+ <addr:name>Joe Doe jr</addr:name>
30248
+ <addr:addr1>Main Street 7</addr:addr1>
30249
+ <addr:addr2>3rd floor</addr:addr2>
30250
+ <addr:addr3>adress 3rd line</addr:addr3>
30251
+ <addr:addr4>adress 4th line</addr:addr4>
30252
+ <addr:phone>+41 55 612 20 54</addr:phone>
30253
+ <addr:fax>+41 55 612 20 55</addr:fax>
30254
+ <addr:email>joe.doe@gmail.com</addr:email>
30255
+ </cust:addr>
30256
+ <cust:shipaddr version="2.0.0">
30257
+ <addr:name>Joe Doe sr</addr:name>
30258
+ <addr:addr1>Main Street 1</addr:addr1>
30259
+ <addr:addr2>line 2</addr:addr2>
30260
+ <addr:addr3>line 3</addr:addr3>
30261
+ <addr:addr4>line 4</addr:addr4>
30262
+ <addr:phone>+41 612 20 55</addr:phone>
30263
+ <addr:fax>+41 612 20 56</addr:fax>
30264
+ <addr:email>joe.doe@gmail-copy.com</addr:email>
30265
+ </cust:shipaddr>
30266
+ <cust:notes>Some remark about the customer</cust:notes>
30267
+ <cust:taxincluded>USEGLOBAL</cust:taxincluded>
30268
+ <cust:active>1</cust:active>
30269
+ <cust:discount>0/1</cust:discount>
30270
+ <cust:credit>0/1</cust:credit>
30271
+ <cust:currency>
30272
+ <cmdty:space>ISO4217</cmdty:space>
30273
+ <cmdty:id>USD</cmdty:id>
30274
+ </cust:currency>
30275
+ <cust:use-tt>0</cust:use-tt>
30276
+ <cust:slots>
30277
+ <slot>
30278
+ <slot:key>last-posted-to-acct</slot:key>
30279
+ <slot:value type="guid">849f778995e8ecf8d4b96940afbbdcd7</slot:value>
30280
+ </slot>
30281
+ <slot>
30282
+ <slot:key>payment</slot:key>
30283
+ <slot:value type="frame">
30284
+ <slot>
30285
+ <slot:key>last_acct</slot:key>
30286
+ <slot:value type="guid">f58e9b550445468e25f277abdeadee91</slot:value>
30287
+ </slot>
30288
+ </slot:value>
30289
+ </slot>
30290
+ </cust:slots>
30291
+ </gnc:GncCustomer>
30241
30292
  </gnc:book>
30242
30293
  </gnc-v2>
30243
30294
 
Binary file
@@ -54,6 +54,12 @@ module Gnucash
54
54
  it "includes transactions that occur on the given date" do
55
55
  expect(@checking.balance_on("2007-03-27")).to eq Value.new(780000)
56
56
  end
57
+
58
+ describe 'with child accounts' do
59
+ it "returns the balance with the balance of the child accounts" do
60
+ expect(@assets.balance_on("2007-03-27", recursive: true)).to eq Value.new(790000)
61
+ end
62
+ end
57
63
  end
58
64
 
59
65
  it "stores whether the account was a placeholder" do
@@ -64,7 +70,36 @@ module Gnucash
64
70
  end
65
71
 
66
72
  it "avoid inspection of heavier attributes" do
67
- expect(@salary.inspect).to eq "#<Gnucash::Account id: efebb6cb617971b0a7f62e9d5a204789, name: Salary, description: Salary, type: INCOME, placeholder: false, parent_id: 35ab61d46f5404895bf5d4949f8a5593>"
73
+ expect(@salary.inspect).to eq "#<Gnucash::Account id: efebb6cb617971b0a7f62e9d5a204789, name: Salary, description: Salary, type: INCOME, code: , placeholder: false, parent_id: 35ab61d46f5404895bf5d4949f8a5593, commodity_space: ISO4217, commodity_id: USD>"
74
+ end
75
+
76
+ context "with pricedb-fixture (account linked to a priced security)" do
77
+ before(:all) do
78
+ @pricedb_book = Gnucash.open("spec/books/pricedb-fixture.gnucash")
79
+ @stocks_account = @pricedb_book.find_account_by_full_name("Stocks")
80
+ end
81
+
82
+ it "returns the Security for the account commodity when it appears in the price database" do
83
+ sec = @stocks_account.security
84
+ expect(sec).not_to be_nil
85
+ expect(sec.space).to eq("TEST")
86
+ expect(sec.id).to eq("STK")
87
+ expect(sec.isin).to eq("US0378331005")
88
+ end
89
+
90
+ it "returns nil when the account commodity has no priced security" do
91
+ brokerage = @pricedb_book.find_account_by_full_name("Brokerage")
92
+ expect(brokerage.security).to be_nil
93
+ end
94
+
95
+ it "exposes the GnuCash account code (act:code)" do
96
+ expect(@pricedb_book.find_account_by_full_name("Brokerage").code).to eq("98234989234")
97
+ expect(@stocks_account.code).to eq("9823498n ewori oio982394")
98
+ end
99
+
100
+ it "returns nil for code when act:code is absent" do
101
+ expect(@pricedb_book.find_account_by_full_name("Root Account").code).to be_nil
102
+ end
68
103
  end
69
104
  end
70
105
  end
@@ -0,0 +1,42 @@
1
+ module Gnucash
2
+ describe Customer do
3
+ @@addr_1 = "\n Joe Doe jr\n Main Street 7\n 3rd floor\n adress 3rd line\n adress 4th line\n +41 55 612 20 54\n +41 55 612 20 55\n joe.doe@gmail.com\n "
4
+ @@addr_2 = "\n Joe Doe sr\n Main Street 1\n line 2\n line 3\n line 4\n +41 612 20 55\n +41 612 20 56\n joe.doe@gmail-copy.com\n "
5
+
6
+ before(:all) do
7
+ # just read the file once
8
+ @book = Gnucash.open("spec/books/sample.gnucash")
9
+ @joe_doe = @book.find_customer_by_full_name("Joe Doe")
10
+ end
11
+
12
+ it "gives access to the name" do
13
+ expect(@joe_doe.name).to eq "Joe Doe"
14
+ end
15
+
16
+ it "gives access to the full name" do
17
+ expect(@joe_doe.full_name).to eq "Joe Doe"
18
+ end
19
+
20
+ it "gives access to the guid" do
21
+ expect(@joe_doe.guid).to eq "0938eaff7545d7ba45fe7b866d60a209"
22
+ end
23
+
24
+ it "gives access to the ID" do
25
+ expect(@joe_doe.id).to eq "8765"
26
+ end
27
+
28
+ it "gives access to the address" do
29
+ expect(@joe_doe.address).to eq @@addr_1
30
+ end
31
+
32
+ it "gives access to the shipping address" do
33
+ expect(@joe_doe.shipping_address).to eq @@addr_2
34
+ end
35
+
36
+ it "avoid inspection of heavier attributes" do
37
+ expect(@joe_doe.inspect).to eq "#<Gnucash::Customer id: 8765, name: Joe Doe, guid: 0938eaff7545d7ba45fe7b866d60a209" +
38
+ ", address: " + @@addr_1 +
39
+ ", shipping_address: " + @@addr_2 + ">"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ module Gnucash
2
+ describe ISIN do
3
+ describe ".normalize" do
4
+ it "strips spaces and hyphens and uppercases" do
5
+ expect(ISIN.normalize("us 03783-31005")).to eq("US0378331005")
6
+ end
7
+ end
8
+
9
+ describe ".valid_format?" do
10
+ it "accepts 12 alphanumeric characters" do
11
+ expect(ISIN.valid_format?("US0378331005")).to be true
12
+ end
13
+
14
+ it "rejects wrong length" do
15
+ expect(ISIN.valid_format?("US037833100")).to be false
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,85 @@
1
+ module Gnucash
2
+ describe Security do
3
+ before(:all) do
4
+ @book = Gnucash.open("spec/books/pricedb-fixture.gnucash")
5
+ @security = @book.find_security("TEST", "STK")
6
+ end
7
+
8
+ it "loads price rows from the fixture" do
9
+ expect(@book.price_rows.size).to eq(4)
10
+ end
11
+
12
+ it "lists securities from the price database" do
13
+ expect(@book.securities.size).to eq(2)
14
+ ids = @book.securities.map(&:id).sort
15
+ expect(ids).to eq(%w[ESFUND STK])
16
+ end
17
+
18
+ it "returns nil when the commodity is not priced" do
19
+ expect(@book.find_security("NONE", "X")).to be_nil
20
+ end
21
+
22
+ describe "ISIN" do
23
+ it "exposes isin from cmdty:xcode" do
24
+ expect(@security.isin).to eq("US0378331005")
25
+ end
26
+
27
+ it "exposes isin from commodity slots" do
28
+ s = @book.find_security("TEST", "ESFUND")
29
+ expect(s.isin).to eq("ES0105046009")
30
+ end
31
+
32
+ it "finds security by ISIN ignoring case and spaces" do
33
+ found = @book.find_security_by_isin("us 03783-31005")
34
+ expect(found.id).to eq("STK")
35
+ expect(@book.find_security_by_isin("ES0105046009").id).to eq("ESFUND")
36
+ end
37
+
38
+ it "returns nil when ISIN is unknown or commodity not priced" do
39
+ expect(@book.find_security_by_isin("DE0000000000")).to be_nil
40
+ end
41
+ end
42
+
43
+ it "supports light inspect on security and quote" do
44
+ expect(@security.inspect).to include("TEST", "STK", "US0378331005")
45
+ q = @security.value_on(Date.new(2020, 6, 1))
46
+ expect(q.inspect).to include("currency_id", "USD")
47
+ end
48
+
49
+ describe "#value_on" do
50
+ it "returns nil when the date is before the first quote" do
51
+ expect(@security.value_on(Date.new(2019, 12, 31))).to be_nil
52
+ end
53
+
54
+ it "returns the most recent quote on or before the date" do
55
+ q = @security.value_on(Date.new(2020, 3, 15))
56
+ expect(q.value).to eq(Value.new("10000/100"))
57
+ expect(q.date).to eq(Date.new(2020, 1, 1))
58
+
59
+ q2 = @security.value_on(Date.new(2020, 6, 1))
60
+ expect(q2.value).to eq(Value.new("15000/100"))
61
+ expect(q2.date).to eq(Date.new(2020, 6, 1))
62
+
63
+ q3 = @security.value_on("2020-12-31")
64
+ expect(q3.date).to eq(Date.new(2020, 6, 1))
65
+ expect(q3.value).to eq(Value.new("15000/100"))
66
+ end
67
+
68
+ it "accepts an explicit quote currency" do
69
+ q = @security.value_on(
70
+ Date.new(2020, 12, 31),
71
+ currency_space: "CURRENCY",
72
+ currency_id: "USD"
73
+ )
74
+ expect(q.value.to_f).to eq(150.0)
75
+ expect(q.date).to eq(Date.new(2020, 6, 1))
76
+ end
77
+
78
+ it "raises when only one currency keyword is given" do
79
+ expect {
80
+ @security.value_on(Date.new(2020, 1, 1), currency_space: "CURRENCY")
81
+ }.to raise_error(ArgumentError)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -32,7 +32,7 @@ module Gnucash
32
32
  end
33
33
 
34
34
  it "avoid inspection of heavier attributes" do
35
- expect(@book.transactions.first.inspect).to eq "#<Gnucash::Transaction id: 12efba30f14dc6cd4c3ffe2994de8284, date: 2007-01-01, description: Opening Balance, splits: [{:account=>#<Gnucash::Account id: 849f778995e8ecf8d4b96940afbbdcd7, name: Checking Account, description: Checking Account, type: BANK, placeholder: false, parent_id: c8e868259d70f6491f9e70ffdf6634ee>, :value=>#<Gnucash::Value val: 30000, div: 100>}, {:account=>#<Gnucash::Account id: 23bea6468ee7b4acb4db4b3f54598a71, name: Opening Balances, description: Opening Balances, type: EQUITY, placeholder: false, parent_id: ea7fe8b8abd560bef49826f68387ca78>, :value=>#<Gnucash::Value val: -30000, div: 100>}]>"
35
+ expect(@book.transactions.first.inspect).to match /#<Gnucash::Transaction.*12efba30f14dc6cd4c3ffe2994de8284.*2007-01-01.*Opening Balance/
36
36
  end
37
37
  end
38
38
  end
data/spec/spec_helper.rb CHANGED
@@ -1,5 +1,9 @@
1
1
  require "simplecov"
2
2
 
3
- SimpleCov.start
3
+ SimpleCov.start do
4
+ minimum_coverage 100
5
+ add_filter "/.bundle/"
6
+ add_filter "/spec/"
7
+ end
4
8
 
5
9
  require "gnucash"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gnucash
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Holtrop
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2018-01-09 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: nokogiri
@@ -103,6 +102,7 @@ extra_rdoc_files: []
103
102
  files:
104
103
  - ".gitignore"
105
104
  - ".rspec"
105
+ - ".travis.yml"
106
106
  - CHANGELOG.md
107
107
  - Gemfile
108
108
  - Gemfile.lock
@@ -114,16 +114,23 @@ files:
114
114
  - lib/gnucash/account.rb
115
115
  - lib/gnucash/account_transaction.rb
116
116
  - lib/gnucash/book.rb
117
+ - lib/gnucash/customer.rb
118
+ - lib/gnucash/isin.rb
119
+ - lib/gnucash/security.rb
117
120
  - lib/gnucash/support.rb
118
121
  - lib/gnucash/support/light_inspect.rb
119
122
  - lib/gnucash/transaction.rb
120
123
  - lib/gnucash/value.rb
121
124
  - lib/gnucash/version.rb
125
+ - spec/books/pricedb-fixture.gnucash
122
126
  - spec/books/sample-text.gnucash
123
127
  - spec/books/sample.gnucash
124
128
  - spec/gnucash/account_spec.rb
125
129
  - spec/gnucash/account_transaction_spec.rb
126
130
  - spec/gnucash/book_spec.rb
131
+ - spec/gnucash/customer_spec.rb
132
+ - spec/gnucash/isin_spec.rb
133
+ - spec/gnucash/security_spec.rb
127
134
  - spec/gnucash/support/light_inspect_spec.rb
128
135
  - spec/gnucash/transaction_spec.rb
129
136
  - spec/gnucash/value_spec.rb
@@ -133,7 +140,6 @@ homepage: https://github.com/holtrop/ruby-gnucash
133
140
  licenses:
134
141
  - MIT
135
142
  metadata: {}
136
- post_install_message:
137
143
  rdoc_options: []
138
144
  require_paths:
139
145
  - lib
@@ -148,17 +154,19 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
154
  - !ruby/object:Gem::Version
149
155
  version: '0'
150
156
  requirements: []
151
- rubyforge_project:
152
- rubygems_version: 2.6.14
153
- signing_key:
157
+ rubygems_version: 4.0.11
154
158
  specification_version: 4
155
159
  summary: Extract data from XML GnuCash data files
156
160
  test_files:
161
+ - spec/books/pricedb-fixture.gnucash
157
162
  - spec/books/sample-text.gnucash
158
163
  - spec/books/sample.gnucash
159
164
  - spec/gnucash/account_spec.rb
160
165
  - spec/gnucash/account_transaction_spec.rb
161
166
  - spec/gnucash/book_spec.rb
167
+ - spec/gnucash/customer_spec.rb
168
+ - spec/gnucash/isin_spec.rb
169
+ - spec/gnucash/security_spec.rb
162
170
  - spec/gnucash/support/light_inspect_spec.rb
163
171
  - spec/gnucash/transaction_spec.rb
164
172
  - spec/gnucash/value_spec.rb