diff --git a/libgnucash/app-utils/gnc-quotes.cpp b/libgnucash/app-utils/gnc-quotes.cpp index 94dc064c83..43113e26e7 100644 --- a/libgnucash/app-utils/gnc-quotes.cpp +++ b/libgnucash/app-utils/gnc-quotes.cpp @@ -96,6 +96,8 @@ public: const std::string& version() noexcept { return m_version.empty() ? not_found : m_version; } const QuoteSources& sources() noexcept { return m_sources; } GList* sources_as_glist (); + const QFVec& failures() noexcept; + std::string report_failures() noexcept; private: std::string query_fq (const CommVec&); @@ -106,6 +108,7 @@ private: std::unique_ptr m_quotesource; std::string m_version; QuoteSources m_sources; + QFVec m_failures; QofBook *m_book; gnc_commodity *m_dflt_curr; }; @@ -129,6 +132,8 @@ private: }; +static const std::string empty_string{}; + GncFQQuoteSource::GncFQQuoteSource() : c_cmd{bp::search_path("perl")}, c_fq_wrapper{std::string(gnc_path_get_bindir()) + "/finance-quote-wrapper"}, @@ -227,8 +232,9 @@ GncFQQuoteSource::run_cmd (const StrVec& args, const std::string& json_string) c /* GncQuotes implementation */ GncQuotesImpl::GncQuotesImpl() : m_quotesource{new GncFQQuoteSource}, -m_version{}, m_sources{}, m_book{qof_session_get_book(gnc_get_current_session())}, -m_dflt_curr{gnc_default_currency()} + m_version{}, m_sources{}, m_failures{}, + m_book{qof_session_get_book(gnc_get_current_session())}, + m_dflt_curr{gnc_default_currency()} { if (!m_quotesource->usable()) return; @@ -283,6 +289,7 @@ GncQuotesImpl::fetch (gnc_commodity *comm) void GncQuotesImpl::fetch (CommVec& commodities) { + m_failures.clear(); if (commodities.empty()) return; @@ -290,6 +297,66 @@ GncQuotesImpl::fetch (CommVec& commodities) parse_quotes (quote_str, commodities); } +const QFVec& +GncQuotesImpl::failures() noexcept +{ + return m_failures; +} + +static std::string +explain(GncQuoteError err, const std::string& errmsg) +{ + std::string retval; + switch (err) + { + case GncQuoteError::NO_RESULT: + if (errmsg.empty()) + retval += _("Finance::Quote returned no data and set no error."); + else + retval += _("Finance::Quote returned an error: ") + errmsg; + break; + case GncQuoteError::QUOTE_FAILED: + if (errmsg.empty()) + retval += _("Finance::Quote reported failure set no error."); + else + retval += _("Finance::Quote reported failure with error: ") + errmsg; + break; + case GncQuoteError::NO_CURRENCY: + retval += _("Finance::Quote returned a quote with no currency."); + break; + case GncQuoteError::UNKNOWN_CURRENCY: + retval += _("Finance::Quote returned a quote with a currency GnuCash doesn't know about."); + break; + case GncQuoteError::NO_PRICE: + retval += _("Finance::Quote returned a quote with no price element."); + break; + case GncQuoteError::PRICE_PARSE_FAILURE: + retval += _("Finance::Quote returned a quote with a price that GnuCash was unable to covert to a number."); + break; + case GncQuoteError::SUCCESS: + default: + retval += _("The quote has no error set."); + break; + } + return retval; +} + +std::string +GncQuotesImpl::report_failures() noexcept +{ + std::string retval{_("Quotes for the following commodities were unavailable or unusable:\n")}; + std::for_each(m_failures.begin(), m_failures.end(), + [&retval](auto failure) + { + auto [ns, sym, reason, err] = failure; + retval += "* " + ns + ":" + sym + " " + + explain(reason, err) + "\n"; + }); + return retval; +} + +/* **** Private function implementations ****/ + std::string GncQuotesImpl::comm_vec_to_json_string (const CommVec& comm_vec) const { @@ -368,11 +435,13 @@ get_price_and_type(PriceParams& p, const bpt::ptree& comm_pt) { p.type = "last"; p.price = comm_pt.get_optional (p.type); + if (!p.price) { p.type = "nav"; p.price = comm_pt.get_optional (p.type); } + if (!p.price) { p.type = "price"; @@ -472,11 +541,13 @@ get_price(const PriceParams& p) } static gnc_commodity* -get_currency(const PriceParams& p, QofBook* book) +get_currency(const PriceParams& p, QofBook* book, QFVec& failures) { if (!p.currency) { - PWARN("Skipped %s:%s - Finance::Quote didn't return a currency", + failures.emplace_back(p.ns, p.mnemonic, GncQuoteError::NO_CURRENCY, + empty_string); + PWARN("Skipped %s:%s - Finance::Quote returned a quote with no currency", p.ns, p.mnemonic); return nullptr; } @@ -487,6 +558,8 @@ get_currency(const PriceParams& p, QofBook* book) if (!currency) { + failures.emplace_back(p.ns, p.mnemonic, + GncQuoteError::UNKNOWN_CURRENCY, empty_string); PWARN("Skipped %s:%s - failed to parse returned currency '%s'", p.ns, p.mnemonic, p.currency->c_str()); return nullptr; @@ -507,6 +580,8 @@ GncQuotesImpl::parse_one_quote(const bpt::ptree& pt, gnc_commodity* comm) auto comm_pt_ai{pt.find(p.mnemonic)}; if (comm_pt_ai == pt.not_found()) { + m_failures.emplace_back(p.ns, p.mnemonic, GncQuoteError::NO_RESULT, + empty_string); PINFO("Skipped %s:%s - Finance::Quote didn't return any data.", p.ns, p.mnemonic); return nullptr; @@ -517,6 +592,8 @@ GncQuotesImpl::parse_one_quote(const bpt::ptree& pt, gnc_commodity* comm) if (!p.success) { + m_failures.emplace_back(p.ns, p.mnemonic, GncQuoteError::QUOTE_FAILED, + p.errormsg ? *p.errormsg : empty_string); PWARN("Skipped %s:%s - Finance::Quote returned fetch failure.\nReason %s", p.ns, p.mnemonic, (p.errormsg ? p.errormsg->c_str() : "unknown")); @@ -525,6 +602,8 @@ GncQuotesImpl::parse_one_quote(const bpt::ptree& pt, gnc_commodity* comm) if (!p.price) { + m_failures.emplace_back(p.ns, p.mnemonic, + GncQuoteError::NO_PRICE, empty_string); PWARN("Skipped %s:%s - Finance::Quote didn't return a valid price", p.ns, p.mnemonic); return nullptr; @@ -532,11 +611,16 @@ GncQuotesImpl::parse_one_quote(const bpt::ptree& pt, gnc_commodity* comm) auto price{get_price(p)}; if (!price) + { + m_failures.emplace_back(p.ns, p.mnemonic, + GncQuoteError::PRICE_PARSE_FAILURE, + empty_string); return nullptr; + } - auto currency{get_currency(p, m_book)}; + auto currency{get_currency(p, m_book, m_failures)}; if (!currency) - return nullptr; + return nullptr; auto quotedt{calc_price_time(p)}; auto gnc_price = gnc_price_create (m_book); @@ -737,3 +821,14 @@ GList* GncQuotes::sources_as_glist () GncQuotes::~GncQuotes() = default; +const QFVec& +GncQuotes::failures() noexcept +{ + return m_impl->failures(); +} + +const std::string +GncQuotes::report_failures() noexcept +{ + return m_impl->report_failures(); +} diff --git a/libgnucash/app-utils/gnc-quotes.hpp b/libgnucash/app-utils/gnc-quotes.hpp index dea38c623f..b3635f7798 100644 --- a/libgnucash/app-utils/gnc-quotes.hpp +++ b/libgnucash/app-utils/gnc-quotes.hpp @@ -33,9 +33,27 @@ extern "C" { #include } -using StrVec = std::vector ; +using StrVec = std::vector; using QuoteSources = StrVec; -using CmdOutput = std::pair ; + +enum class GncQuoteError +{ + SUCCESS, + NO_RESULT, + QUOTE_FAILED, + NO_CURRENCY, + UNKNOWN_CURRENCY, + NO_PRICE, + UNKNOWN_PRICE_TYPE, + PRICE_PARSE_FAILURE, +}; + +/** QuoteFailure elements are namespace, mnemonic, error code, and + * F::Q errormsg if there is one. + */ +using QuoteFailure = std::tuple; +using QFVec = std::vector; struct GncQuoteException : public std::runtime_error { @@ -93,6 +111,23 @@ public: */ GList* sources_as_glist () ; + /** Report the commodities for which quotes were requested but not successfully retrieved. + * + * This does not include requested commodities that didn't have a quote source. + * + * @return a reference to a vector of QuoteFailure tuples. + * @note The vector and its contents belong to the GncQuotes object and will be destroyed with it. + */ + const QFVec& failures() noexcept; + + /* Report the commodities for which quotes were requested but not successfully retrieved. + * + * This does not include requested commodities that didn't have a quote source. + * + * @return A localized std::string with an intro and a list of the quote failures with a cause. The string is owned by the caller. + */ + const std::string report_failures() noexcept; + private: std::unique_ptr m_impl; }; diff --git a/libgnucash/app-utils/test/gtest-gnc-quotes.cpp b/libgnucash/app-utils/test/gtest-gnc-quotes.cpp index a7fe4bc921..ebcd66d04b 100644 --- a/libgnucash/app-utils/test/gtest-gnc-quotes.cpp +++ b/libgnucash/app-utils/test/gtest-gnc-quotes.cpp @@ -141,7 +141,11 @@ TEST_F(GncQuotesTest, online_wiggle) GncQuotes quotes; quotes.fetch(m_book); auto pricedb{gnc_pricedb_get_db(m_book)}; - EXPECT_EQ(3u, gnc_pricedb_get_num_prices(pricedb)); + auto failures{quotes.failures()}; + ASSERT_EQ(2u, failures.size()); + EXPECT_EQ(GncQuoteError::QUOTE_FAILED, std::get<2>(failures[0])); + EXPECT_EQ(GncQuoteError::QUOTE_FAILED, std::get<2>(failures[1])); + EXPECT_EQ(2u, gnc_pricedb_get_num_prices(pricedb)); } #endif @@ -153,6 +157,9 @@ TEST_F(GncQuotesTest, offline_wiggle) StrVec err_vec; GncQuotesImpl quotes(m_book, std::make_unique(std::move(quote_vec), std::move(err_vec))); quotes.fetch(m_book); + auto failures{quotes.failures()}; + ASSERT_EQ(1u, failures.size()); + EXPECT_EQ(GncQuoteError::QUOTE_FAILED, std::get<2>(failures[0])); auto pricedb{gnc_pricedb_get_db(m_book)}; EXPECT_EQ(3u, gnc_pricedb_get_num_prices(pricedb)); } @@ -169,7 +176,9 @@ TEST_F(GncQuotesTest, comvec_fetch) CommVec comms{hpe, aapl}; GncQuotesImpl quotes(m_book, std::make_unique(std::move(quote_vec), std::move(err_vec))); quotes.fetch(comms); - auto pricedb{gnc_pricedb_get_db(m_book)}; + auto failures{quotes.failures()}; + EXPECT_TRUE(failures.empty()); + auto pricedb{gnc_pricedb_get_db(m_book)}; EXPECT_EQ(2u, gnc_pricedb_get_num_prices(pricedb)); } @@ -184,6 +193,8 @@ TEST_F(GncQuotesTest, fetch_one_commodity) auto usd{gnc_commodity_table_lookup(commtable, "ISO4217", "USD")}; GncQuotesImpl quotes(m_book, std::make_unique(std::move(quote_vec), std::move(err_vec))); quotes.fetch(hpe); + auto failures{quotes.failures()}; + EXPECT_TRUE(failures.empty()); auto pricedb{gnc_pricedb_get_db(m_book)}; auto price{gnc_pricedb_lookup_latest(pricedb, hpe, usd)}; auto datetime{static_cast(GncDateTime("20220901160000"))}; @@ -208,8 +219,11 @@ TEST_F(GncQuotesTest, fetch_one_currency) auto usd{gnc_commodity_table_lookup(commtable, "ISO4217", "USD")}; GncQuotesImpl quotes(m_book, std::make_unique(std::move(quote_vec), std::move(err_vec))); quotes.fetch(eur); + auto failures{quotes.failures()}; + EXPECT_TRUE(failures.empty()); auto pricedb{gnc_pricedb_get_db(m_book)}; auto price{gnc_pricedb_lookup_latest(pricedb, eur, usd)}; + EXPECT_EQ(1u, gnc_pricedb_get_num_prices(pricedb)); auto datetime{static_cast(GncDateTime())}; EXPECT_EQ(usd, gnc_price_get_currency(price)); @@ -222,3 +236,65 @@ TEST_F(GncQuotesTest, fetch_one_currency) EXPECT_STREQ("last", gnc_price_get_typestr(price)); } +TEST_F(GncQuotesTest, no_currency) +{ + StrVec quote_vec{ + "{\"HPE\":{\"date\":\"09/01/2022\",\"last\":13.37,\"success\":1}}" + }; + StrVec err_vec; + auto commtable{gnc_commodity_table_get_table(m_book)}; + auto hpe{gnc_commodity_table_lookup(commtable, "NYSE", "HPE")}; + auto usd{gnc_commodity_table_lookup(commtable, "ISO4217", "USD")}; + GncQuotesImpl quotes(m_book, std::make_unique(std::move(quote_vec), std::move(err_vec))); + quotes.fetch(hpe); + auto failures{quotes.failures()}; + ASSERT_EQ(1u, failures.size()); + EXPECT_EQ(GncQuoteError::NO_CURRENCY, std::get<2>(failures[0])); + auto pricedb{gnc_pricedb_get_db(m_book)}; + EXPECT_EQ(0u, gnc_pricedb_get_num_prices(pricedb)); +} + +TEST_F(GncQuotesTest, bad_currency) +{ + StrVec quote_vec{ + "{\"HPE\":{\"date\":\"09/01/2022\",\"last\":13.37,\"currency\":\"BTC\",\"success\":1}}" + }; + StrVec err_vec; + auto commtable{gnc_commodity_table_get_table(m_book)}; + auto hpe{gnc_commodity_table_lookup(commtable, "NYSE", "HPE")}; + auto usd{gnc_commodity_table_lookup(commtable, "ISO4217", "USD")}; + GncQuotesImpl quotes(m_book, std::make_unique(std::move(quote_vec), std::move(err_vec))); + quotes.fetch(hpe); + auto failures{quotes.failures()}; + ASSERT_EQ(1u, failures.size()); + EXPECT_EQ(GncQuoteError::UNKNOWN_CURRENCY, std::get<2>(failures[0])); + auto pricedb{gnc_pricedb_get_db(m_book)}; + EXPECT_EQ(0u, gnc_pricedb_get_num_prices(pricedb)); +} + +TEST_F(GncQuotesTest, no_date) +{ + StrVec quote_vec{ + "{\"HPE\":{\"last\":13.37,\"currency\":\"USD\",\"success\":1}}" + }; + StrVec err_vec; + auto commtable{gnc_commodity_table_get_table(m_book)}; + auto hpe{gnc_commodity_table_lookup(commtable, "NYSE", "HPE")}; + auto usd{gnc_commodity_table_lookup(commtable, "ISO4217", "USD")}; + GncQuotesImpl quotes(m_book, std::make_unique(std::move(quote_vec), std::move(err_vec))); + quotes.fetch(hpe); + auto failures{quotes.failures()}; + EXPECT_TRUE(failures.empty()); + auto pricedb{gnc_pricedb_get_db(m_book)}; + auto price{gnc_pricedb_lookup_latest(pricedb, hpe, usd)}; + auto datetime{static_cast(GncDateTime())}; + + EXPECT_EQ(usd, gnc_price_get_currency(price)); + EXPECT_EQ(datetime, gnc_price_get_time64(price)); + EXPECT_EQ(PRICE_SOURCE_FQ, gnc_price_get_source(price)); + EXPECT_TRUE(gnc_numeric_equal(GncNumeric{1337, 100}, + gnc_price_get_value(price))); + EXPECT_STREQ("Finance::Quote", gnc_price_get_source_string(price)); + EXPECT_STREQ("last", gnc_price_get_typestr(price)); +} +