Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ tmp
test.rb
spec/books/*.gnucash.*.*
spec/books/*.LCK
.DS_Store
.vscode/
bin/
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ source 'https://rubygems.org'

# Specify your gem's dependencies in gnucash.gemspec
gemspec

group :development do
# rdbg / Cursor: depuración (vscode-rdbg)
gem "debug", ">= 1.9", require: false
end
18 changes: 17 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
PATH
remote: .
specs:
gnucash (1.5.0)
gnucash (1.6.0)
nokogiri

GEM
remote: https://rubygems.org/
specs:
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
diff-lcs (1.6.2)
docile (1.4.1)
erb (6.0.2)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
mini_portile2 (2.8.9)
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
Expand All @@ -31,6 +40,10 @@ GEM
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.3.1)
date
stringio
Expand All @@ -40,6 +53,8 @@ GEM
erb
psych (>= 4.0.0)
tsort
reline (0.6.3)
io-console (~> 0.5)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
Expand Down Expand Up @@ -75,6 +90,7 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
debug (>= 1.9)
gnucash!
rake
rdoc
Expand Down
2 changes: 1 addition & 1 deletion gnucash.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Gem::Specification.new do |gem|
gem.homepage = "https://github.com/holtrop/ruby-gnucash"
gem.license = "MIT"

gem.files = `git ls-files`.split($/)
gem.files = `git ls-files`.split($/).reject { |f| f == "bin/rdbg" }
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
gem.require_paths = ["lib"]
Expand Down
2 changes: 2 additions & 0 deletions lib/gnucash.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
require_relative "gnucash/account"
require_relative "gnucash/customer"
require_relative "gnucash/account_transaction"
require_relative "gnucash/isin"
require_relative "gnucash/security"
require_relative "gnucash/book"
require_relative "gnucash/transaction"
require_relative "gnucash/value"
Expand Down
42 changes: 41 additions & 1 deletion lib/gnucash/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,28 @@ class Account
# @return [String] The GUID of the account.
attr_reader :id

# @return [String, nil] Account code (+act:code+), if set in GnuCash.
#
# @since 1.6.0
attr_reader :code

# @return [Array<AccountTransaction>]
# List of transactions associated with this account.
attr_reader :transactions

# @return [Boolean] Whether the account is a placeholder or not.
attr_reader :placeholder

# @return [String, nil] Commodity namespace (+cmdty:space+) from +act:commodity+, if present.
#
# @since 1.6.0
attr_reader :commodity_space

# @return [String, nil] Commodity id (+cmdty:id+) from +act:commodity+, if present.
#
# @since 1.6.0
attr_reader :commodity_id

# @since 1.4.0
#
# @return [String, nil] The GUID of the parent account, if any.
Expand All @@ -38,6 +53,8 @@ def initialize(book, node)
@type = node.xpath('act:type').text
@description = node.xpath('act:description').text
@id = node.xpath('act:id').text
code_raw = node.at_xpath('act:code')&.text
@code = (code_raw.nil? || code_raw.empty?) ? nil : code_raw
@parent_id = node.xpath('act:parent').text
@parent_id = nil if @parent_id == ""
@transactions = []
Expand All @@ -46,6 +63,29 @@ def initialize(book, node)
(slot.xpath("slot:key").first.text == "placeholder" and
slot.xpath("slot:value").first.text == "true")
end ? true : false

cmd = node.at_xpath("act:commodity")
if cmd
@commodity_space = cmd.at_xpath("cmdty:space")&.text&.strip
@commodity_id = cmd.at_xpath("cmdty:id")&.text&.strip
@commodity_space = nil if @commodity_space.nil? || @commodity_space.empty?
@commodity_id = nil if @commodity_id.nil? || @commodity_id.empty?
else
@commodity_space = nil
@commodity_id = nil
end
end

# Priced {Security} for this account's commodity, if the commodity appears in the
# book's price database (+gnc:pricedb+). Otherwise +nil+.
#
# @since 1.6.0
#
# @return [Security, nil]
def security
return nil unless @commodity_space && @commodity_id

@book.find_security(@commodity_space, @commodity_id)
end

# Return the fully qualified account name.
Expand Down Expand Up @@ -141,7 +181,7 @@ def balance_on(date, options = {})
# @return [Array<Symbol>] Attributes used to build the inspection string
# @see Gnucash::Support::LightInspect
def attributes
%i[id name description type placeholder parent_id]
%i[id name description type code placeholder parent_id commodity_space commodity_id]
end

private
Expand Down
147 changes: 147 additions & 0 deletions lib/gnucash/book.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "date"
require "zlib"
require "nokogiri"

Expand All @@ -6,6 +7,11 @@ module Gnucash
class Book
include Support::LightInspect

# One row from +gnc:pricedb+.
#
# @since 1.6.0
PriceRow = Struct.new(:commodity_space, :commodity_id, :currency_space, :currency_id, :date, :value)

# @return [Array<Account>] Accounts in the book.
attr_reader :accounts

Expand All @@ -16,6 +22,12 @@ class Book
# @return [Array<Transaction>] Transactions in the book.
attr_reader :transactions

# @return [Array<PriceRow>]
# Raw price-database rows (commodity/currency/value/date). Prefer
# {Security#value_on} for valuations.
# @since 1.6.0
attr_reader :price_rows

# @return [Date] Date of the first transaction in the book.
attr_reader :start_date

Expand Down Expand Up @@ -43,6 +55,8 @@ def initialize(fname)
build_customers
build_accounts
build_transactions
build_price_quotes
build_commodity_isin_index
finalize
end

Expand Down Expand Up @@ -79,6 +93,63 @@ def find_customer_by_full_name(full_name)
@customers.find { |a| a.full_name == full_name }
end

# Return every {Security} that appears in the price database (unique commodity).
#
# @since 1.6.0
#
# @return [Array<Security>]
def securities
@securities ||= @price_rows.map { |r| [r.commodity_space, r.commodity_id] }.uniq.map do |space, id|
Security.new(self, space, id)
end
end

# Look up a security by GnuCash commodity +space+ and +id+.
#
# @since 1.6.0
#
# @return [Security, nil]
def find_security(space, id)
return nil unless @price_rows.any? { |r| r.commodity_space == space && r.commodity_id == id }
Security.new(self, space, id)
end

# Look up a priced security whose commodity defines this ISIN (+cmdty:xcode+ or a slot
# whose key matches +isin+, e.g. +user:ISIN+). Comparison ignores spaces, hyphens and case.
#
# @since 1.6.0
#
# @param isin [String] ISIN as stored or typed (e.g. +"US0378331005"+).
#
# @return [Security, nil]
def find_security_by_isin(isin)
key = ISIN.normalize(isin)
return nil if key.empty?

pair = @isin_index[key]
return nil unless pair

find_security(pair[0], pair[1])
end

# ISIN for a commodity if present in the book (+nil+ otherwise).
#
# @since 1.6.0
#
# @return [String, nil] Normalized ISIN (12 uppercase alphanumeric characters).
def isin_for_commodity(space, id)
@isin_for_commodity[[space, id]]
end

# Price-database rows for one commodity (used by {Security#value_on}).
#
# @since 1.6.0
#
# @return [Array<PriceRow>]
def quotes_for_commodity(space, id)
@price_rows.select { |r| r.commodity_space == space && r.commodity_id == id }
end

# Attributes available for inspection
#
# @return [Array<Symbol>] Attributes used to build the inspection string
Expand Down Expand Up @@ -114,6 +185,82 @@ def build_transactions
end
end

# @return [void]
def build_price_quotes
pricedb = @book_node.at_xpath('gnc:pricedb')
@price_rows = []
return unless pricedb

pricedb.element_children.each do |node|
next unless node.element?
row = parse_price_node(node)
@price_rows << row if row
end
end

# @return [PriceRow, nil]
def parse_price_node(node)
return nil unless node.at_xpath('price:commodity')

cmd = node.at_xpath('price:commodity')
cur = node.at_xpath('price:currency')
return nil unless cmd && cur

commodity_space = cmd.at_xpath('cmdty:space')&.text
commodity_id = cmd.at_xpath('cmdty:id')&.text
currency_space = cur.at_xpath('cmdty:space')&.text
currency_id = cur.at_xpath('cmdty:id')&.text
return nil if [commodity_space, commodity_id, currency_space, currency_id].any? { |s| s.nil? || s.empty? }

ts = node.at_xpath('price:time/ts:date')&.text
return nil if ts.nil? || ts.empty?

date = Date.parse(ts.split(' ').first)
val_text = node.at_xpath('price:value')&.text
return nil if val_text.nil? || val_text.empty?

PriceRow.new(commodity_space, commodity_id, currency_space, currency_id, date, Value.new(val_text))
end

# @return [void]
def build_commodity_isin_index
@isin_for_commodity = {}
@isin_index = {}

@book_node.xpath("gnc:commodity").each do |node|
space = node.at_xpath("cmdty:space")&.text&.strip
id = node.at_xpath("cmdty:id")&.text&.strip
next if space.nil? || id.nil? || space.empty? || id.empty?

raw = extract_isin_from_commodity_node(node)
next unless raw

key = ISIN.normalize(raw)
next unless ISIN.valid_format?(key)

@isin_for_commodity[[space, id]] = key
@isin_index[key] ||= [space, id]
end
end

# @return [String, nil] raw ISIN string from XML before normalization
def extract_isin_from_commodity_node(node)
node.xpath(".//slot").each do |slot|
k = slot.at_xpath("slot:key")&.text
next unless k&.match?(/isin/i)

v = slot.at_xpath("slot:value")&.text&.strip
next if v.nil? || v.empty?

return v if ISIN.valid_format?(v)
end

xcode = node.at_xpath("cmdty:xcode")&.text&.strip
return xcode if xcode && ISIN.valid_format?(xcode)

nil
end

# @return [void]
def finalize
@accounts.sort! do |a, b|
Expand Down
25 changes: 25 additions & 0 deletions lib/gnucash/isin.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module Gnucash
# Helpers for ISIN (ISO 6166) strings as stored on GnuCash commodities.
#
# @since 1.6.0
module ISIN
module_function

# @param str [String, nil]
# @return [String] Uppercase ISIN with spaces and hyphens removed.
def normalize(str)
return "" if str.nil? || str.to_s.empty?
str.to_s.gsub(/[\s-]/, "").upcase
end

# Rough format check: 12 alphanumeric characters after normalization.
#
# @param str [String, nil]
# @return [Boolean]
def valid_format?(str)
s = normalize(str)
return false if s.length != 12
s.match?(/\A[A-Z0-9]{12}\z/)
end
end
end
Loading