From 52a12572cb6448f27a3896a5d4505239b6735b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Santiago=20=C3=81lvarez?= Date: Wed, 15 Apr 2026 21:16:47 +0200 Subject: [PATCH 1/2] Add ISIN and Security support with account/spec updates --- Gemfile | 5 + Gemfile.lock | 18 +++- gnucash.gemspec | 2 +- lib/gnucash.rb | 2 + lib/gnucash/account.rb | 42 ++++++++- lib/gnucash/book.rb | 147 +++++++++++++++++++++++++++++ lib/gnucash/isin.rb | 25 +++++ lib/gnucash/security.rb | 114 ++++++++++++++++++++++ lib/gnucash/version.rb | 2 +- spec/books/pricedb-fixture.gnucash | Bin 0 -> 1202 bytes spec/gnucash/account_spec.rb | 31 +++++- spec/gnucash/isin_spec.rb | 19 ++++ spec/gnucash/security_spec.rb | 87 +++++++++++++++++ 13 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 lib/gnucash/isin.rb create mode 100644 lib/gnucash/security.rb create mode 100644 spec/books/pricedb-fixture.gnucash create mode 100644 spec/gnucash/isin_spec.rb create mode 100644 spec/gnucash/security_spec.rb diff --git a/Gemfile b/Gemfile index 24a260b..3c805c6 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 416de0f..17216d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 @@ -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) @@ -75,6 +90,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + debug (>= 1.9) gnucash! rake rdoc diff --git a/gnucash.gemspec b/gnucash.gemspec index ca2126c..a3e70a1 100644 --- a/gnucash.gemspec +++ b/gnucash.gemspec @@ -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"] diff --git a/lib/gnucash.rb b/lib/gnucash.rb index fd34d8e..dbee7a1 100644 --- a/lib/gnucash.rb +++ b/lib/gnucash.rb @@ -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" diff --git a/lib/gnucash/account.rb b/lib/gnucash/account.rb index 9fc3691..d5c7365 100644 --- a/lib/gnucash/account.rb +++ b/lib/gnucash/account.rb @@ -15,6 +15,11 @@ 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] # List of transactions associated with this account. attr_reader :transactions @@ -22,6 +27,16 @@ class Account # @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. @@ -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 = [] @@ -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. @@ -141,7 +181,7 @@ def balance_on(date, options = {}) # @return [Array] 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 diff --git a/lib/gnucash/book.rb b/lib/gnucash/book.rb index 5508b13..bf1c91b 100644 --- a/lib/gnucash/book.rb +++ b/lib/gnucash/book.rb @@ -1,3 +1,4 @@ +require "date" require "zlib" require "nokogiri" @@ -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] Accounts in the book. attr_reader :accounts @@ -16,6 +22,12 @@ class Book # @return [Array] Transactions in the book. attr_reader :transactions + # @return [Array] + # 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 @@ -43,6 +55,8 @@ def initialize(fname) build_customers build_accounts build_transactions + build_price_quotes + build_commodity_isin_index finalize end @@ -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] + 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] + 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] Attributes used to build the inspection string @@ -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| diff --git a/lib/gnucash/isin.rb b/lib/gnucash/isin.rb new file mode 100644 index 0000000..b03c5c4 --- /dev/null +++ b/lib/gnucash/isin.rb @@ -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 diff --git a/lib/gnucash/security.rb b/lib/gnucash/security.rb new file mode 100644 index 0000000..53397fd --- /dev/null +++ b/lib/gnucash/security.rb @@ -0,0 +1,114 @@ +require "date" + +module Gnucash + # Price quote for a security (commodity) from the GnuCash price database: the + # value of one unit of the security expressed in +currency+ as of +date+. + # + # @since 1.6.0 + class SecurityQuote + include Support::LightInspect + + # @return [Value] Price of one unit of the security in the quote currency. + attr_reader :value + + # @return [String] Commodity namespace (GnuCash "space") of the quote currency. + attr_reader :currency_space + + # @return [String] Commodity id of the quote currency (e.g. +"USD"+). + attr_reader :currency_id + + # @return [Date] Date of the price in the book (quote time, date-only). + attr_reader :date + + def initialize(value:, currency_space:, currency_id:, date:) + @value = value + @currency_space = currency_space + @currency_id = currency_id + @date = date + end + + def attributes + %i[value currency_space currency_id date] + end + end + + # A security (stock, fund, etc.) identified by its GnuCash commodity + # +space+ and +id+, with prices loaded from the book's price database. + # + # @since 1.6.0 + class Security + include Support::LightInspect + + # @return [String] Commodity namespace (e.g. +"NASDAQ"+, +"ISO4217"+). + attr_reader :space + + # @return [String] Commodity id (e.g. ticker or currency code). + attr_reader :id + + # @param book [Book] Parent book. + # @param space [String] Commodity space. + # @param id [String] Commodity id. + def initialize(book, space, id) + @book = book + @space = space + @id = id + end + + # @return [String, nil] ISIN from the commodity definition (+cmdty:xcode+ or ISIN slot), normalized. + def isin + @book.isin_for_commodity(@space, @id) + end + + # Return the price quote whose date is closest to the given valuation date. + # Distance is measured in calendar days; if two quotes are equally close, the + # earlier quote is used. + # + # If the security has multiple quote currencies, +currency_space+ and + # +currency_id+ select one; if omitted, USD (+ISO4217+ / +USD+) is preferred + # when present, otherwise an arbitrary quote chain is used. + # + # @param date [String, Date] Valuation date. + # @param currency_space [String, nil] Restrict to this quote currency space. + # @param currency_id [String, nil] Restrict to this quote currency id. + # + # @return [SecurityQuote, nil] Quote used for valuation, or nil if none applies. + def value_on(date, currency_space: nil, currency_id: nil) + date = Date.parse(date) if date.is_a?(String) + if (currency_space.nil? ^ currency_id.nil?) + raise ArgumentError, "currency_space and currency_id must both be set or both omitted" + end + + quotes = @book.quotes_for_commodity(@space, @id) + return nil if quotes.empty? + + filtered = + if currency_space + quotes.select { |q| q.currency_space == currency_space && q.currency_id == currency_id } + else + quotes + end + return nil if filtered.empty? + + pick_currency = lambda do |list| + usd = list.select { |q| q.currency_space == "ISO4217" && q.currency_id == "USD" } + (usd.empty? ? list : usd) + end + + candidates = currency_space ? filtered : pick_currency.call(filtered) + return nil if candidates.empty? + + best = candidates.min_by { |q| [(q.date - date).abs, q.date] } + + SecurityQuote.new( + value: best.value, + currency_space: best.currency_space, + currency_id: best.currency_id, + date: best.date + ) + end + + def attributes + %i[space id isin] + end + end +end diff --git a/lib/gnucash/version.rb b/lib/gnucash/version.rb index e417205..3f6fda8 100644 --- a/lib/gnucash/version.rb +++ b/lib/gnucash/version.rb @@ -1,4 +1,4 @@ module Gnucash # gem version - VERSION = "1.5.0" + VERSION = "1.6.0" end diff --git a/spec/books/pricedb-fixture.gnucash b/spec/books/pricedb-fixture.gnucash new file mode 100644 index 0000000000000000000000000000000000000000..41ac99b31345a18b08ac02d5c323f9f1d8a549d6 GIT binary patch literal 1202 zcmV;j1Wo%NiwFP!000006YW@AkJ>mCexF|v@wBVmOfFmo5R+=!ok~^PsWgDpK2^as zOqL{O9fwPQ{o0O0;*dD7(@L|_Sb!p*@3YUbzZ@Skd9#l~V~a=<;P|%VT-zN3#Q?(~ zUfp(5x-^F!p8oj1nhf)^po=qI5I1tle&Ry^2%dCr{UytgNq}KUvJ) zDdT-WwRmP%@C%IBS^@_s3;?xw09AAZ=|NB62!*pD0YF-gKq#Cf#QMNFKxJ&gpoPK_ zDrc`J>=iU6u@=tkDn8M}69v80qn9drvC_xN>4TCWl}SzLAM}eSF;(7%V7&k&MtuX)M86mupfFIh3Go5xS3U=+%$O4W z+a?eSCy2MW?R}FNh4(jJXgSQT;4y(nzsUlja&~bWha=Rt$lp_c5$XfVK`N8Bkf@5^ zq8MVmdl{sYrS>tvY0Q;HeFMNTJ#5%~BENdh#8Ls0!L7x?;a=P9=wK$^Jr1Ce4Y5iC z*zx?9HnAit=ktu1cr2g^fAU0R;&Hr22ali#Z;=Ubh>00vY9@$%Ib~uUK_$-C4^t!^ zN+@9`TgRg$MG`H{Vlpe6O}ZnUXj~qEqllf8?1b>T4Qo_K^MUBSjFg=!UayfNT{+zl z7M3{1*rDPB8xZsj|2G_4T*{!fZ2N~}_^==q-Dl(2DR z#y#YG#aQLg9~NJ0#s7=rncYuknpp0q@8=Ko;lzG~y!UAgja@*W48GgOhv|oh94zbw zd`Vb+!aVUDo3$Kt>@E3~YgbWkS-)w5$T*uym8(v$_fy-k`*v?&+oP&*3+eJF#f@LQ zzSG&q=Ea?%gc?(d!Mb5h_pHuDOmBAgX4vhrh3)6S%yRzfKQ~Ou&n%xJO{i7KX}ncL zZM0QTbEM}+DRKi`l#2K)KG84bA)c#g8MU;DmD7|_O7lz6whE?%YIrHRaBGLpCzY9{ zcFxA6rp|kk*h-Ts!^=5o5EY})WXuvS@?6`sO}lH_j$zwl@iG3$M43aBk&8j(s4^Rh zBp`ZB#G=I{CluMTaD;7|m&WUul@!Nud0!cnWrLn;+m^$f_Nt3|UA)+Q;k4(AtQe8Shb%?p z-KMtOVns|=5j|p7Y2RhYiFNGGF%@Y!EiD^C7GwNTMD+OO%Pg~G0cOrh_bzevEx~I< z{1s~V)NN_?p1!?%_*nK-+N$zBQ>VIHoOLp;I+K}z)Dv5N+FgKIu{s*M-QH+8V!t^S z7M??t01|9O#adatvMGnRUzZks=*LXoVGsrMoGTM6m?PA&syCy?3B(@>m)yn3YVn)f zz4_Da4%oN_zXFV+gQ@BWwgBM*}#WO0GB-nL4N=SBWF2; zs6V_JjT+v%&-2;*?$d>8Zb" + expect(@salary.inspect).to eq "#" + end + + context "with pricedb-fixture (account linked to a priced security)" do + before(:all) do + @pricedb_book = Gnucash.open("spec/books/pricedb-fixture.gnucash") + @stocks_account = @pricedb_book.find_account_by_full_name("Stocks") + end + + it "returns the Security for the account commodity when it appears in the price database" do + sec = @stocks_account.security + expect(sec).not_to be_nil + expect(sec.space).to eq("TEST") + expect(sec.id).to eq("STK") + expect(sec.isin).to eq("US0378331005") + end + + it "returns nil when the account commodity has no priced security" do + brokerage = @pricedb_book.find_account_by_full_name("Brokerage") + expect(brokerage.security).to be_nil + end + + it "exposes the GnuCash account code (act:code)" do + expect(@pricedb_book.find_account_by_full_name("Brokerage").code).to eq("98234989234") + expect(@stocks_account.code).to eq("9823498n ewori oio982394") + end + + it "returns nil for code when act:code is absent" do + expect(@pricedb_book.find_account_by_full_name("Root Account").code).to be_nil + end end end end diff --git a/spec/gnucash/isin_spec.rb b/spec/gnucash/isin_spec.rb new file mode 100644 index 0000000..db6b125 --- /dev/null +++ b/spec/gnucash/isin_spec.rb @@ -0,0 +1,19 @@ +module Gnucash + describe ISIN do + describe ".normalize" do + it "strips spaces and hyphens and uppercases" do + expect(ISIN.normalize("us 03783-31005")).to eq("US0378331005") + end + end + + describe ".valid_format?" do + it "accepts 12 alphanumeric characters" do + expect(ISIN.valid_format?("US0378331005")).to be true + end + + it "rejects wrong length" do + expect(ISIN.valid_format?("US037833100")).to be false + end + end + end +end diff --git a/spec/gnucash/security_spec.rb b/spec/gnucash/security_spec.rb new file mode 100644 index 0000000..0658284 --- /dev/null +++ b/spec/gnucash/security_spec.rb @@ -0,0 +1,87 @@ +module Gnucash + describe Security do + before(:all) do + @book = Gnucash.open("spec/books/pricedb-fixture.gnucash") + @security = @book.find_security("TEST", "STK") + end + + it "loads price rows from the fixture" do + expect(@book.price_rows.size).to eq(4) + end + + it "lists securities from the price database" do + expect(@book.securities.size).to eq(2) + ids = @book.securities.map(&:id).sort + expect(ids).to eq(%w[ESFUND STK]) + end + + it "returns nil when the commodity is not priced" do + expect(@book.find_security("NONE", "X")).to be_nil + end + + describe "ISIN" do + it "exposes isin from cmdty:xcode" do + expect(@security.isin).to eq("US0378331005") + end + + it "exposes isin from commodity slots" do + s = @book.find_security("TEST", "ESFUND") + expect(s.isin).to eq("ES0105046009") + end + + it "finds security by ISIN ignoring case and spaces" do + found = @book.find_security_by_isin("us 03783-31005") + expect(found.id).to eq("STK") + expect(@book.find_security_by_isin("ES0105046009").id).to eq("ESFUND") + end + + it "returns nil when ISIN is unknown or commodity not priced" do + expect(@book.find_security_by_isin("DE0000000000")).to be_nil + end + end + + it "supports light inspect on security and quote" do + expect(@security.inspect).to include("TEST", "STK", "US0378331005") + q = @security.value_on(Date.new(2020, 6, 1)) + expect(q.inspect).to include("currency_id", "USD") + end + + describe "#value_on" do + it "uses the closest quote by calendar distance (also before the first quote)" do + q = @security.value_on(Date.new(2019, 12, 31)) + expect(q.date).to eq(Date.new(2020, 1, 1)) + expect(q.value).to eq(Value.new("10000/100")) + end + + it "picks the nearest quote on either side of the date" do + q = @security.value_on(Date.new(2020, 3, 15)) + expect(q.value).to eq(Value.new("10000/100")) + expect(q.date).to eq(Date.new(2020, 1, 1)) + + q2 = @security.value_on(Date.new(2020, 6, 1)) + expect(q2.value).to eq(Value.new("15000/100")) + expect(q2.date).to eq(Date.new(2020, 6, 1)) + + q3 = @security.value_on("2020-12-31") + expect(q3.date).to eq(Date.new(2021, 1, 1)) + expect(q3.value).to eq(Value.new("20000/100")) + end + + it "accepts an explicit quote currency" do + q = @security.value_on( + Date.new(2020, 12, 31), + currency_space: "CURRENCY", + currency_id: "USD" + ) + expect(q.value.to_f).to eq(200.0) + expect(q.date).to eq(Date.new(2021, 1, 1)) + end + + it "raises when only one currency keyword is given" do + expect { + @security.value_on(Date.new(2020, 1, 1), currency_space: "CURRENCY") + }.to raise_error(ArgumentError) + end + end + end +end From 91655591ab5192c3d3d2bb7cd54582a4e1e279b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Santiago=20=C3=81lvarez?= Date: Wed, 15 Apr 2026 21:23:53 +0200 Subject: [PATCH 2/2] Ignore local editor and binstub files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 58709a5..10933b3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ tmp test.rb spec/books/*.gnucash.*.* spec/books/*.LCK +.DS_Store +.vscode/ +bin/