From 3a467d758e86f50bc28501f4806297a6c4eb75ad Mon Sep 17 00:00:00 2001 From: Mykyta Lytvynenko Date: Sun, 20 Apr 2025 01:15:32 +0300 Subject: [PATCH] add National Bank of Ukraine exchange rates data source --- conf/ezbookkeeping.ini | 1 + pkg/api/exchange_rates_test.go | 18 ++ .../exchange_rates_datasource_container.go | 3 + .../national_bank_of_ukraine_datasource.go | 138 ++++++++++++++ ...ational_bank_of_ukraine_datasource_test.go | 176 ++++++++++++++++++ pkg/settings/setting.go | 4 +- 6 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 pkg/exchangerates/national_bank_of_ukraine_datasource.go create mode 100644 pkg/exchangerates/national_bank_of_ukraine_datasource_test.go diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index aa04c18c..cb3a5025 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -362,6 +362,7 @@ custom_map_tile_server_default_zoom_level = 14 # "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates # "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/ # "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx +# "national_bank_of_ukraine": https://bank.gov.ua/ua/markets/exchangerates data_source = euro_central_bank # Requesting exchange rates data timeout (0 - 4294967295 milliseconds) diff --git a/pkg/api/exchange_rates_test.go b/pkg/api/exchange_rates_test.go index af4aba1e..d7178298 100644 --- a/pkg/api/exchange_rates_test.go +++ b/pkg/api/exchange_rates_test.go @@ -292,6 +292,24 @@ func TestExchangeRatesApiLatestExchangeRateHandler_InternationalMonetaryFundData checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates) } +func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfUkraineDataSource(t *testing.T) { + exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfUkraineDataSource) + + if exchangeRateResponse == nil { + return + } + + assert.Equal(t, "UAH", exchangeRateResponse.BaseCurrency) + + supportedCurrencyCodes := []string{ + "AED", "AUD", "AZN", "BDT", "BGN", "CAD", "CHF", "CNY", "CZK", "DKK", + "DZD", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "ILS", "INR", + "JPY", "KRW", "KZT", "LBP", "MDL", "MXN", "MYR", "NOK", "NZD", "PLN", + "RON", "RSD", "SAR", "SEK", "SGD", "THB", "TND", "TRY", "USD", "VND", "ZAR"} + + checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates) +} + func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *models.LatestExchangeRateResponse { config := &settings.Config{ ExchangeRatesDataSource: dataSourceType, diff --git a/pkg/exchangerates/exchange_rates_datasource_container.go b/pkg/exchangerates/exchange_rates_datasource_container.go index fa868fcd..ac9c6bd4 100644 --- a/pkg/exchangerates/exchange_rates_datasource_container.go +++ b/pkg/exchangerates/exchange_rates_datasource_container.go @@ -65,6 +65,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error { } else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource { Container.Current = &InternationalMonetaryFundDataSource{} return nil + } else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource { + Container.Current = &NationalBankOfUkraineDataSource{} + return nil } return errs.ErrInvalidExchangeRatesDataSource diff --git a/pkg/exchangerates/national_bank_of_ukraine_datasource.go b/pkg/exchangerates/national_bank_of_ukraine_datasource.go new file mode 100644 index 00000000..8b4e2c24 --- /dev/null +++ b/pkg/exchangerates/national_bank_of_ukraine_datasource.go @@ -0,0 +1,138 @@ +package exchangerates + +import ( + "encoding/json" + "math" + "net/http" + "time" + + "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 nationalBankOfUkraineExchangeRateUrl = "https://bank.gov.ua/NBU_Exchange/exchange?json" +const nationalBankOfUkraineExchangeRateReferenceUrl = "https://bank.gov.ua/ua/markets/exchangerates" +const nationalBankOfUkraineDataSource = "Національний банк України" +const nationalBankOfUkraineBaseCurrency = "UAH" + +const nationalBankOfUkraineUpdateDateFormat = "02.01.2006" + +// NationalBankOfUkraineDataSource defines the structure of exchange rates data source of National Bank of Ukraine +type NationalBankOfUkraineDataSource struct { + ExchangeRatesDataSource +} + +// NationalBankOfUkraineExchangeRates represents the exchange rates data from National Bank of Ukraine +type NationalBankOfUkraineExchangeRates []NaionalBankOfUkraineExchangeRate + +// NaionalBankOfUkraineExchangeRate represents the exchange rate data from National Bank of Ukraine +type NaionalBankOfUkraineExchangeRate struct { + Currency string `json:"CurrencyCodeL"` + Quantity float64 `json:"Units"` + Rate float64 `json:"Amount"` + Date string `json:"StartDate"` +} + +// ToLatestExchangeRateResponse returns a view-object according to original data from National Bank of Ukraine +func (e *NationalBankOfUkraineExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse { + exchangeRates := make(models.LatestExchangeRateSlice, 0, len(*e)) + latestUpdateTime := int64(0) + + for _, exchangeRate := range *e { + if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists { + continue + } + + updateTime, err := time.Parse(nationalBankOfUkraineUpdateDateFormat, exchangeRate.Date) + + if err != nil { + log.Errorf(c, "[national_bank_of_ukraine_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Date) + return nil + } + + if updateTime.Unix() > latestUpdateTime { + latestUpdateTime = updateTime.Unix() + } + + finalExchangeRate := exchangeRate.ToLatestExchangeRate(c) + + if finalExchangeRate == nil { + continue + } + + exchangeRates = append(exchangeRates, finalExchangeRate) + } + + latestExchangeRateResp := &models.LatestExchangeRateResponse{ + DataSource: nationalBankOfUkraineDataSource, + ReferenceUrl: nationalBankOfUkraineExchangeRateReferenceUrl, + UpdateTime: latestUpdateTime, + BaseCurrency: nationalBankOfUkraineBaseCurrency, + ExchangeRates: exchangeRates, + } + + return latestExchangeRateResp +} + +// ToLatestExchangeRate returns a data pair according to original data from National Bank of Ukraine +func (e *NaionalBankOfUkraineExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate { + if e.Rate <= 0 { + log.Warnf(c, "[national_bank_of_ukraine_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %f", e.Currency, e.Rate) + return nil + } + + if e.Quantity <= 0 { + log.Warnf(c, "[national_bank_of_ukraine_datasource.ToLatestExchangeRate] quantity is invalid, currency is %s, quantity is %f", e.Currency, e.Quantity) + return nil + } + + finalRate := e.Quantity / e.Rate + + if math.IsInf(finalRate, 0) { + return nil + } + + return &models.LatestExchangeRate{ + Currency: e.Currency, + Rate: utils.Float64ToString(finalRate), + } +} + +// BuildRequests returns the National Bank of Ukraine exchange rates http requests +func (e *NationalBankOfUkraineDataSource) BuildRequests() ([]*http.Request, error) { + req, err := http.NewRequest("GET", nationalBankOfUkraineExchangeRateUrl, nil) + + if err != nil { + return nil, err + } + + return []*http.Request{req}, nil +} + +// Parse returns the common response entity according to the National Bank of Ukraine data source raw response +func (e *NationalBankOfUkraineDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) { + var nationalBankOfUkraineData NationalBankOfUkraineExchangeRates + err := json.Unmarshal(content, &nationalBankOfUkraineData) + + if err != nil { + log.Errorf(c, "[national_bank_of_ukraine_datasource.Parse] failed to parse JSON data, content: %s, error: %s", string(content), err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + if len(nationalBankOfUkraineData) == 0 { + log.Errorf(c, "[national_bank_of_ukraine_datasource.Parse] exchange rate list is empty") + return nil, errs.ErrFailedToRequestRemoteApi + } + + latestExchangeRateResponse := nationalBankOfUkraineData.ToLatestExchangeRateResponse(c) + if latestExchangeRateResponse == nil { + log.Errorf(c, "[national_bank_of_ukraine_datasource.Parse] failed to parse latest exchange rate data, content: %s", string(content)) + return nil, errs.ErrFailedToRequestRemoteApi + } + + return latestExchangeRateResponse, nil +} diff --git a/pkg/exchangerates/national_bank_of_ukraine_datasource_test.go b/pkg/exchangerates/national_bank_of_ukraine_datasource_test.go new file mode 100644 index 00000000..abf5b242 --- /dev/null +++ b/pkg/exchangerates/national_bank_of_ukraine_datasource_test.go @@ -0,0 +1,176 @@ +package exchangerates + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +const nationalBankOfUkraineMinimumRequiredContent = "[\n" + + " {\n" + + " \"StartDate\": \"21.04.2025\",\n" + + " \"TimeSign\": \"0000\",\n" + + " \"CurrencyCode\": \"840\",\n" + + " \"CurrencyCodeL\": \"USD\",\n" + + " \"Units\": 1,\n" + + " \"Amount\": 41.3955\n" + + " },\n" + + " {\n" + + " \"StartDate\": \"21.04.2025\",\n" + + " \"TimeSign\": \"0000\",\n" + + " \"CurrencyCode\": \"392\",\n" + + " \"CurrencyCodeL\": \"JPY\",\n" + + " \"Units\": 10,\n" + + " \"Amount\": 2.907\n" + + " }\n" + + "]" + +func TestNationalBankOfUkraineDataSource_StandardDataExtractBaseCurrency(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfUkraineMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Equal(t, "UAH", actualLatestExchangeRateResponse.BaseCurrency) +} + +func TestNationalBankOfUkraineDataSource_StandardDataExtractUpdateTime(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfUkraineMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Equal(t, int64(1745193600), actualLatestExchangeRateResponse.UpdateTime) +} + +func TestNationalBankOfUkraineDataSource_StandardDataExtractExchangeRates(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfUkraineMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "USD", + Rate: "0.02415721515623679", + }) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "JPY", + Rate: "3.4399724802201583", + }) +} + +func TestNationalBankOfUkraineDataSource_BlankContent(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("")) + assert.NotEqual(t, nil, err) +} + +func TestNationalBankOfUkraineDataSource_EmptyData(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("[]")) + assert.NotEqual(t, nil, err) +} + +func TestNationalBankOfUkraineDataSource_InvalidDate(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("[\n"+ + " {\n"+ + " \"StartDate\": \"04.21.2025\",\n"+ + " \"TimeSign\": \"0000\",\n"+ + " \"CurrencyCode\": \"840\",\n"+ + " \"CurrencyCodeL\": \"USD\",\n"+ + " \"Units\": 1,\n"+ + " \"Amount\": 41.3955\n"+ + " }\n"+ + "]")) + assert.NotEqual(t, nil, err) +} + +func TestNationalBankOfUkraineDataSource_InvalidCurrency(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+ + " {\n"+ + " \"StartDate\": \"21.04.2025\",\n"+ + " \"TimeSign\": \"0000\",\n"+ + " \"CurrencyCode\": \"840\",\n"+ + " \"CurrencyCodeL\": \"XXX\",\n"+ + " \"Units\": 1,\n"+ + " \"Amount\": 41.3955\n"+ + " }\n"+ + "]")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestNationalBankOfUkraineDataSource_InvalidUnits(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+ + " {\n"+ + " \"StartDate\": \"21.04.2025\",\n"+ + " \"TimeSign\": \"0000\",\n"+ + " \"CurrencyCode\": \"840\",\n"+ + " \"CurrencyCodeL\": \"USD\",\n"+ + " \"Units\": null,\n"+ + " \"Amount\": 41.3955\n"+ + " }\n"+ + "]")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) + + actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+ + " {\n"+ + " \"StartDate\": \"21.04.2025\",\n"+ + " \"TimeSign\": \"0000\",\n"+ + " \"CurrencyCode\": \"840\",\n"+ + " \"CurrencyCodeL\": \"USD\",\n"+ + " \"Units\": 0,\n"+ + " \"Amount\": 41.3955\n"+ + " }\n"+ + "]")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestNationalBankOfUkraineDataSource_InvalidAmount(t *testing.T) { + dataSource := &NationalBankOfUkraineDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+ + " {\n"+ + " \"StartDate\": \"21.04.2025\",\n"+ + " \"TimeSign\": \"0000\",\n"+ + " \"CurrencyCode\": \"840\",\n"+ + " \"CurrencyCodeL\": \"USD\",\n"+ + " \"Units\": 1,\n"+ + " \"Amount\": null\n"+ + " }\n"+ + "]")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) + + actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+ + " {\n"+ + " \"StartDate\": \"21.04.2025\",\n"+ + " \"TimeSign\": \"0000\",\n"+ + " \"CurrencyCode\": \"840\",\n"+ + " \"CurrencyCodeL\": \"USD\",\n"+ + " \"Units\": 1,\n"+ + " \"Amount\": 0\n"+ + " }\n"+ + "]")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index bf702537..b5107913 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -116,6 +116,7 @@ const ( SwissNationalBankDataSource string = "swiss_national_bank" CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan" InternationalMonetaryFundDataSource string = "international_monetary_fund" + NationalBankOfUkraineDataSource string = "national_bank_of_ukraine" ) const ( @@ -910,7 +911,8 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio dataSource == BankOfRussiaDataSource || dataSource == SwissNationalBankDataSource || dataSource == CentralBankOfUzbekistanDataSource || - dataSource == InternationalMonetaryFundDataSource { + dataSource == InternationalMonetaryFundDataSource || + dataSource == NationalBankOfUkraineDataSource { config.ExchangeRatesDataSource = dataSource } else { return errs.ErrInvalidExchangeRatesDataSource