diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index 84d2c25f..99a1febe 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -350,6 +350,7 @@ custom_map_tile_server_default_zoom_level = 14 # "norges_bank": https://www.norges-bank.no/en/topics/Statistics/exchange_rates/ # "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/ # "national_bank_of_romania": https://www.bnr.ro/Exchange-rates-1224.aspx +# "bank_of_russia": https://www.cbr.ru/eng/currency_base/daily/ # "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates # "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx data_source = euro_central_bank diff --git a/pkg/api/exchange_rates_test.go b/pkg/api/exchange_rates_test.go index 7dbdd8ca..7568b620 100644 --- a/pkg/api/exchange_rates_test.go +++ b/pkg/api/exchange_rates_test.go @@ -193,6 +193,23 @@ func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfRomaniaDataSour checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates) } +func TestExchangeRatesApiLatestExchangeRateHandler_BankOfRussiaDataSource(t *testing.T) { + exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfRussiaDataSource) + + if exchangeRateResponse == nil { + return + } + + assert.Equal(t, "RUB", exchangeRateResponse.BaseCurrency) + + supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BGN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK", + "DKK", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "INR", "JPY", "KGS", "KRW", "KZT", "MDL", + "NOK", "NZD", "PLN", "QAR", "RON", "RSD", "SEK", "SGD", "THB", "TJS", "TMT", "TRY", + "UAH", "USD", "UZS", "VND", "ZAR"} + + checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates) +} + func TestExchangeRatesApiLatestExchangeRateHandler_SwissNationalBankDataSource(t *testing.T) { exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.SwissNationalBankDataSource) diff --git a/pkg/exchangerates/bank_of_russia_datasource.go b/pkg/exchangerates/bank_of_russia_datasource.go new file mode 100644 index 00000000..c072c47b --- /dev/null +++ b/pkg/exchangerates/bank_of_russia_datasource.go @@ -0,0 +1,149 @@ +package exchangerates + +import ( + "bytes" + "encoding/xml" + "math" + "strings" + "time" + + "golang.org/x/net/html/charset" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" + "github.com/mayswind/ezbookkeeping/pkg/validators" +) + +const bankOfRussiaExchangeRateUrl = "https://cbr.ru/scripts/XML_daily_eng.asp" +const bankOfRussiaExchangeRateReferenceUrl = "https://www.cbr.ru/eng/currency_base/daily/" +const bankOfRussiaDataSource = "Bank of Russia" +const bankOfRussiaBaseCurrency = "RUB" + +const bankOfRussiaUpdateDateFormat = "02.01.2006 15:04" +const bankOfRussiaUpdateDateTimezone = "Europe/Moscow" + +// BankOfRussiaDataSource defines the structure of exchange rates data source of bank of Russia +type BankOfRussiaDataSource struct { + ExchangeRatesDataSource +} + +// BankOfRussiaExchangeRateData represents the whole data from bank of Russia +type BankOfRussiaExchangeRateData struct { + XMLName xml.Name `xml:"ValCurs"` + Date string `xml:"Date,attr"` + ExchangeRates []*BankOfRussiaExchangeRate `xml:"Valute"` +} + +// BankOfRussiaExchangeRate represents the exchange rate data from bank of Russia +type BankOfRussiaExchangeRate struct { + Currency string `xml:"CharCode"` + Rate string `xml:"VunitRate"` +} + +// ToLatestExchangeRateResponse returns a view-object according to original data from bank of Russia +func (e *BankOfRussiaExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse { + if len(e.ExchangeRates) < 1 { + log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] all exchange rates is empty") + return nil + } + + exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.ExchangeRates)) + + for i := 0; i < len(e.ExchangeRates); i++ { + exchangeRate := e.ExchangeRates[i] + + if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists { + continue + } + + finalExchangeRate := exchangeRate.ToLatestExchangeRate(c) + + if finalExchangeRate == nil { + continue + } + + exchangeRates = append(exchangeRates, finalExchangeRate) + } + + timezone, err := time.LoadLocation(bankOfRussiaUpdateDateTimezone) + + if err != nil { + log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", bankOfRussiaUpdateDateTimezone) + return nil + } + + updateDateTime := e.Date + " 15:30" // the Bank of Russia switches to setting official exchange rates of foreign currencies against the ruble as of 15:30 Moscow time. + updateTime, err := time.ParseInLocation(bankOfRussiaUpdateDateFormat, updateDateTime, timezone) + + if err != nil { + log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime) + return nil + } + + latestExchangeRateResp := &models.LatestExchangeRateResponse{ + DataSource: bankOfRussiaDataSource, + ReferenceUrl: bankOfRussiaExchangeRateReferenceUrl, + UpdateTime: updateTime.Unix(), + BaseCurrency: bankOfRussiaBaseCurrency, + ExchangeRates: exchangeRates, + } + + return latestExchangeRateResp +} + +// ToLatestExchangeRate returns a data pair according to original data from bank of Russia +func (e *BankOfRussiaExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate { + rate, err := utils.StringToFloat64(strings.ReplaceAll(e.Rate, ",", ".")) + + if err != nil { + log.Warnf(c, "[bank_of_russia_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate) + return nil + } + + if rate <= 0 { + log.Warnf(c, "[bank_of_russia_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate) + return nil + } + + finalRate := 1 / rate + + if math.IsInf(finalRate, 0) { + return nil + } + + return &models.LatestExchangeRate{ + Currency: e.Currency, + Rate: utils.Float64ToString(finalRate), + } +} + +// GetRequestUrls returns the bank of Russia data source urls +func (e *BankOfRussiaDataSource) GetRequestUrls() []string { + return []string{bankOfRussiaExchangeRateUrl} +} + +// Parse returns the common response entity according to the bank of Russia data source raw response +func (e *BankOfRussiaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) { + xmlDecoder := xml.NewDecoder(bytes.NewReader(content)) + xmlDecoder.CharsetReader = charset.NewReaderLabel + + bankOfRussiaData := &BankOfRussiaExchangeRateData{} + err := xmlDecoder.Decode(bankOfRussiaData) + + if err != nil { + log.Errorf(c, "[bank_of_russia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + latestExchangeRateResponse := bankOfRussiaData.ToLatestExchangeRateResponse(c) + + if latestExchangeRateResponse == nil { + log.Errorf(c, "[bank_of_russia_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content)) + return nil, errs.ErrFailedToRequestRemoteApi + } + + return latestExchangeRateResponse, nil +} diff --git a/pkg/exchangerates/bank_of_russia_datasource_test.go b/pkg/exchangerates/bank_of_russia_datasource_test.go new file mode 100644 index 00000000..2676c9ee --- /dev/null +++ b/pkg/exchangerates/bank_of_russia_datasource_test.go @@ -0,0 +1,137 @@ +package exchangerates + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +const bankOfRussiaDataSourceMinimumRequiredContent = "\n" + + "\n" + + " \n" + + " USD\n" + + " 99,9971\n" + + " \n" + + " \n" + + " CNY\n" + + " 13,7992\n" + + " \n" + + "" + +func TestBankOfRussiaDataSource_StandardDataExtractBaseCurrency(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Equal(t, "RUB", actualLatestExchangeRateResponse.BaseCurrency) +} + +func TestBankOfRussiaDataSource_StandardDataExtractUpdateTime(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Equal(t, int64(1731760200), actualLatestExchangeRateResponse.UpdateTime) +} + +func TestBankOfRussiaDataSource_StandardDataExtractExchangeRates(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "USD", + Rate: "0.010000290008410243", + }) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "CNY", + Rate: "0.07246796915763232", + }) +} + +func TestBankOfRussiaDataSource_BlankContent(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("")) + assert.NotEqual(t, nil, err) +} + +func TestBankOfRussiaDataSource_OnlyXMLHeader(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("")) + assert.NotEqual(t, nil, err) +} + +func TestBankOfRussiaDataSource_EmptyExchangeRatesDataset(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte(""+ + ""+ + "")) + assert.NotEqual(t, nil, err) +} + +func TestBankOfRussiaDataSource_InvalidCurrency(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(""+ + ""+ + " \n"+ + " XXX\n"+ + " 1\n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestBankOfRussiaDataSource_EmptyRate(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(""+ + ""+ + " \n"+ + " USD\n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestBankOfRussiaDataSource_InvalidRate(t *testing.T) { + dataSource := &BankOfRussiaDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(""+ + ""+ + " \n"+ + " USD\n"+ + " null\n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) + + actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte(""+ + ""+ + " \n"+ + " USD\n"+ + " 0\n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} diff --git a/pkg/exchangerates/exchange_rates_datasource_container.go b/pkg/exchangerates/exchange_rates_datasource_container.go index 3b598b81..fc1ba6fe 100644 --- a/pkg/exchangerates/exchange_rates_datasource_container.go +++ b/pkg/exchangerates/exchange_rates_datasource_container.go @@ -47,6 +47,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error { } else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource { Container.Current = &NationalBankOfRomaniaDataSource{} return nil + } else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource { + Container.Current = &BankOfRussiaDataSource{} + return nil } else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource { Container.Current = &SwissNationalBankDataSource{} return nil diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index 32901d00..93ade7aa 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -110,6 +110,7 @@ const ( NorgesBankDataSource string = "norges_bank" NationalBankOfPolandDataSource string = "national_bank_of_poland" NationalBankOfRomaniaDataSource string = "national_bank_of_romania" + BankOfRussiaDataSource string = "bank_of_russia" SwissNationalBankDataSource string = "swiss_national_bank" InternationalMonetaryFundDataSource string = "international_monetary_fund" ) @@ -894,6 +895,7 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio dataSource == NorgesBankDataSource || dataSource == NationalBankOfPolandDataSource || dataSource == NationalBankOfRomaniaDataSource || + dataSource == BankOfRussiaDataSource || dataSource == SwissNationalBankDataSource || dataSource == InternationalMonetaryFundDataSource { config.ExchangeRatesDataSource = dataSource