From 416e7cece180b3da892d95eaf0201998d743666c Mon Sep 17 00:00:00 2001
From: vigdail <36927936+vigdail@users.noreply.github.com>
Date: Tue, 28 Apr 2026 19:23:41 +0500
Subject: [PATCH] add the National Bank of Kazakhstan exchange rates data
source (#564)
* add the National Bank of Kazakhstan exchange rates data source
* fix import order, sort exchange rate data by country name.
* fix National Bank of Kazakhstan exchange rate reference url
* add integration test for the National Bank of Kazakhstan exchange rate provider
---
conf/ezbookkeeping.ini | 1 +
..._http_exchange_rates_data_provider_test.go | 16 ++
.../exchange_rates_data_provider_container.go | 3 +
.../national_bank_of_kazakhstan_datasource.go | 160 +++++++++++++++
...onal_bank_of_kazakhstan_datasource_test.go | 182 ++++++++++++++++++
pkg/settings/setting.go | 34 ++--
6 files changed, 380 insertions(+), 16 deletions(-)
create mode 100644 pkg/exchangerates/national_bank_of_kazakhstan_datasource.go
create mode 100644 pkg/exchangerates/national_bank_of_kazakhstan_datasource_test.go
diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini
index 74e1dc5c..b31b2f46 100644
--- a/conf/ezbookkeeping.ini
+++ b/conf/ezbookkeeping.ini
@@ -536,6 +536,7 @@ custom_map_tile_server_default_zoom_level = 14
# "national_bank_of_georgia": https://nbg.gov.ge/en/monetary-policy/currency
# "central_bank_of_hungary": https://www.mnb.hu/en/arfolyamok
# "bank_of_israel": https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/
+# "national_bank_of_kazakhstan": https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut
# "central_bank_of_myanmar": https://forex.cbm.gov.mm/index.php/fxrate
# "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/
diff --git a/pkg/exchangerates/common_http_exchange_rates_data_provider_test.go b/pkg/exchangerates/common_http_exchange_rates_data_provider_test.go
index 532fc963..52224b72 100644
--- a/pkg/exchangerates/common_http_exchange_rates_data_provider_test.go
+++ b/pkg/exchangerates/common_http_exchange_rates_data_provider_test.go
@@ -135,6 +135,22 @@ func TestExchangeRatesApiLatestExchangeRateHandler_BankOfIsraelDataSource(t *tes
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
}
+func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfKazakhstan(t *testing.T) {
+ exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfKazakhstanDataSource)
+
+ if exchangeRateResponse == nil {
+ return
+ }
+
+ assert.Equal(t, "KZT", exchangeRateResponse.BaseCurrency)
+
+ supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK",
+ "DKK", "EUR", "GBP", "GEL", "HKD", "HUF", "INR", "IRR", "JPY", "KGS", "KRW", "KWD", "MDL", "MXN",
+ "MYR", "NOK", "PLN", "RUB", "SAR", "SEK", "SGD", "THB", "TJS", "TRY", "UAH", "USD", "UZS", "ZAR"}
+
+ checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
+}
+
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfMyanmarDataSource(t *testing.T) {
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfMyanmarDataSource)
diff --git a/pkg/exchangerates/exchange_rates_data_provider_container.go b/pkg/exchangerates/exchange_rates_data_provider_container.go
index df115b0c..d530d2cb 100644
--- a/pkg/exchangerates/exchange_rates_data_provider_container.go
+++ b/pkg/exchangerates/exchange_rates_data_provider_container.go
@@ -40,6 +40,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error {
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfIsraelDataSource{})
return nil
+ } else if config.ExchangeRatesDataSource == settings.NationalBankOfKazakhstanDataSource {
+ Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfKazakhstanDataSource{})
+ return nil
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfMyanmarDataSource{})
return nil
diff --git a/pkg/exchangerates/national_bank_of_kazakhstan_datasource.go b/pkg/exchangerates/national_bank_of_kazakhstan_datasource.go
new file mode 100644
index 00000000..5624516c
--- /dev/null
+++ b/pkg/exchangerates/national_bank_of_kazakhstan_datasource.go
@@ -0,0 +1,160 @@
+package exchangerates
+
+import (
+ "encoding/xml"
+ "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 nationalBankOfKazakhstanExchangeRateUrl = "https://www.nationalbank.kz/rss/rates_all.xml"
+const nationalBankOfKazakhstanExchangeRateReferenceUrl = "https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut"
+const nationalBankOfKazakhstanDataSource = "Қазақстан Республикасының Ұлттық Банкі"
+const nationalBankOfKazakhstanBaseCurrency = "KZT"
+
+const nationalBankOfKazakhstanUpdateDateFormat = "02.01.2006"
+const nationalBankOfKazakhstanUpdateDateTimezone = "Asia/Almaty"
+
+// NationalBankOfKazakhstanDataSource defines the structure of exchange rates data source of the national bank of Kazakhstan
+type NationalBankOfKazakhstanDataSource struct {
+ HttpExchangeRatesDataSource
+}
+
+// NationalBankOfKazakhstanExchangeRates represents the exchange rates data from the national bank of Kazakhstan
+type NationalBankOfKazakhstanExchangeRates struct {
+ Channel struct {
+ Items []*NationalBankOfKazakhstanExchangeRate `xml:"item"`
+ } `xml:"channel"`
+}
+
+// NationalBankOfKazakhstanExchangeRate represents the exchange rate data from the national bank of Kazakhstan
+type NationalBankOfKazakhstanExchangeRate struct {
+ Currency string `xml:"title"`
+ Rate string `xml:"description"`
+ Unit string `xml:"quant"`
+ Date string `xml:"pubDate"`
+}
+
+// ToLatestExchangeRateResponse returns a view-object according to original data from the national bank of Kazakhstan
+func (e *NationalBankOfKazakhstanExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
+ if e == nil || len(e.Channel.Items) < 1 {
+ log.Errorf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
+ return nil
+ }
+
+ timezone, err := time.LoadLocation(nationalBankOfKazakhstanUpdateDateTimezone)
+ if err != nil {
+ log.Errorf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] failed to load timezone, timezone name is %s", nationalBankOfKazakhstanUpdateDateTimezone)
+ return nil
+ }
+
+ exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Channel.Items))
+ latestUpdateTime := int64(0)
+
+ for i := 0; i < len(e.Channel.Items); i++ {
+ exchangeRate := e.Channel.Items[i]
+
+ if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
+ continue
+ }
+
+ updateTime, err := time.ParseInLocation(nationalBankOfKazakhstanUpdateDateFormat, exchangeRate.Date, timezone)
+
+ if err != nil {
+ log.Errorf(c, "[central_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Date)
+ return nil
+ }
+
+ if updateTime.Unix() > latestUpdateTime {
+ latestUpdateTime = updateTime.Unix()
+ }
+
+ finalRate := exchangeRate.ToLatestExchangeRate(c)
+ if finalRate == nil {
+ continue
+ }
+
+ exchangeRates = append(exchangeRates, finalRate)
+ }
+
+ return &models.LatestExchangeRateResponse{
+ DataSource: nationalBankOfKazakhstanDataSource,
+ ReferenceUrl: nationalBankOfKazakhstanExchangeRateReferenceUrl,
+ UpdateTime: latestUpdateTime,
+ BaseCurrency: nationalBankOfKazakhstanBaseCurrency,
+ ExchangeRates: exchangeRates,
+ }
+}
+
+// ToLatestExchangeRate returns a data pair according to original data from the national bank of Kazakhstan
+func (e *NationalBankOfKazakhstanExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
+ rate, err := utils.StringToFloat64(e.Rate)
+ if err != nil {
+ log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
+ return nil
+ }
+
+ if rate <= 0 {
+ log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
+ return nil
+ }
+
+ unit, err := utils.StringToFloat64(e.Unit)
+ if err != nil {
+ log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] failed to parse unit, currency=%s, unit=%s", e.Currency, e.Unit)
+ }
+
+ if unit <= 0 {
+ log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] unit is less or equal zero, currency is %s, unit is %s", e.Currency, e.Unit)
+ return nil
+ }
+
+ finalRate := rate / unit
+ if math.IsInf(finalRate, 0) {
+ log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] final exchange rate calculation failed, currency is %s, unit is %s, rate is %s", e.Currency, e.Unit, e.Rate)
+ return nil
+ }
+
+ return &models.LatestExchangeRate{
+ Currency: e.Currency,
+ Rate: utils.Float64ToString(finalRate),
+ }
+}
+
+// BuildRequests returns the national bank of Kazakhstan exchange rates http requests
+func (e *NationalBankOfKazakhstanDataSource) BuildRequests() ([]*http.Request, error) {
+ req, err := http.NewRequest("GET", nationalBankOfKazakhstanExchangeRateUrl, nil)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return []*http.Request{req}, nil
+}
+
+// Parse returns the common response entity according to the national bank of Kazakhstan data source raw response
+func (e *NationalBankOfKazakhstanDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
+ nationalBankOfKazakhstanData := &NationalBankOfKazakhstanExchangeRates{}
+ err := xml.Unmarshal(content, nationalBankOfKazakhstanData)
+
+ if err != nil {
+ log.Errorf(c, "[national_bank_of_kazakhstan_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
+ return nil, errs.ErrFailedToRequestRemoteApi
+ }
+
+ latestExchangeRateResponse := nationalBankOfKazakhstanData.ToLatestExchangeRateResponse(c)
+
+ if latestExchangeRateResponse == nil {
+ log.Errorf(c, "[national_bank_of_kazakhstan_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/national_bank_of_kazakhstan_datasource_test.go b/pkg/exchangerates/national_bank_of_kazakhstan_datasource_test.go
new file mode 100644
index 00000000..c5d1dfff
--- /dev/null
+++ b/pkg/exchangerates/national_bank_of_kazakhstan_datasource_test.go
@@ -0,0 +1,182 @@
+package exchangerates
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/mayswind/ezbookkeeping/pkg/core"
+ "github.com/mayswind/ezbookkeeping/pkg/models"
+)
+
+const nationalBankOfKazakhstanMinimumRequiredContent = "\n" +
+ " \n" +
+ " \n" +
+ " - \n" +
+ " USD\n" +
+ " 28.04.2026\n" +
+ " 450.50\n" +
+ " 1\n" +
+ "
\n" +
+ " - \n" +
+ " VND\n" +
+ " 28.04.2026\n" +
+ " 0.018\n" +
+ " 10\n" +
+ "
\n" +
+ " \n" +
+ ""
+
+func TestNationalBankOfKazakhstanDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
+ dataSource := &NationalBankOfKazakhstanDataSource{}
+ context := core.NewNullContext()
+
+ resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
+ assert.Equal(t, nil, err)
+ assert.Equal(t, "KZT", resp.BaseCurrency)
+}
+
+func TestNationalBankOfKazakhstanDataSource_StandardDataExtractUpdateTime(t *testing.T) {
+ dataSource := &NationalBankOfKazakhstanDataSource{}
+ context := core.NewNullContext()
+
+ resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
+ assert.Equal(t, nil, err)
+
+ assert.Equal(t, int64(1777316400), resp.UpdateTime)
+}
+
+func TestNationalBankOfKazakhstanDataSource_StandardDataExtractExchangeRates(t *testing.T) {
+ dataSource := &NationalBankOfKazakhstanDataSource{}
+ context := core.NewNullContext()
+
+ resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
+ assert.Equal(t, nil, err)
+
+ assert.Contains(t, resp.ExchangeRates, &models.LatestExchangeRate{
+ Currency: "USD",
+ Rate: "450.5",
+ })
+
+ assert.Contains(t, resp.ExchangeRates, &models.LatestExchangeRate{
+ Currency: "VND",
+ Rate: "0.0018",
+ })
+}
+
+func TestNationalBankOfKazakhstanDataSource_BlankContent(t *testing.T) {
+ dataSource := &NationalBankOfKazakhstanDataSource{}
+ context := core.NewNullContext()
+
+ _, err := dataSource.Parse(context, []byte(""))
+ assert.NotEqual(t, nil, err)
+}
+
+func TestNationalBankOfKazakhstanDataSource_EmptyData(t *testing.T) {
+ dataSource := &NationalBankOfKazakhstanDataSource{}
+ context := core.NewNullContext()
+
+ content := "\n" +
+ "\n" +
+ "\n" +
+ "\n" +
+ ""
+
+ _, err := dataSource.Parse(context, []byte(content))
+ assert.NotEqual(t, nil, err)
+}
+
+func TestNationalBankOfKazakhstanDataSource_InvalidCurrency(t *testing.T) {
+ dataSource := &NationalBankOfKazakhstanDataSource{}
+ context := core.NewNullContext()
+
+ content := "\n" +
+ " \n" +
+ " \n" +
+ " - \n" +
+ " XXX\n" +
+ " 28.04.2026\n" +
+ " 450.50\n" +
+ " 1\n" +
+ "
\n" +
+ " \n" +
+ ""
+
+ resp, err := dataSource.Parse(context, []byte(content))
+ assert.Equal(t, nil, err)
+ assert.Len(t, resp.ExchangeRates, 0)
+}
+
+func TestNationalBankOfKazakhstanDataSource_InvalidUnit(t *testing.T) {
+ dataSource := &NationalBankOfKazakhstanDataSource{}
+ context := core.NewNullContext()
+
+ content := "\n" +
+ " \n" +
+ " \n" +
+ " - \n" +
+ " USD\n" +
+ " 28.04.2026\n" +
+ " 450.50\n" +
+ " null\n" +
+ "
\n" +
+ " \n" +
+ ""
+
+ resp, err := dataSource.Parse(context, []byte(content))
+ assert.Equal(t, nil, err)
+ assert.Len(t, resp.ExchangeRates, 0)
+
+ content = "\n" +
+ " \n" +
+ " \n" +
+ " - \n" +
+ " USD\n" +
+ " 28.04.2026\n" +
+ " 450.50\n" +
+ " 0\n" +
+ "
\n" +
+ " \n" +
+ ""
+
+ resp, err = dataSource.Parse(context, []byte(content))
+ assert.Equal(t, nil, err)
+ assert.Len(t, resp.ExchangeRates, 0)
+}
+
+func TestNationalBankOfKazakhstanDataSource_InvalidRate(t *testing.T) {
+ dataSource := &NationalBankOfKazakhstanDataSource{}
+ context := core.NewNullContext()
+
+ content := "\n" +
+ " \n" +
+ " \n" +
+ " - \n" +
+ " USD\n" +
+ " 28.04.2026\n" +
+ " null\n" +
+ " 1\n" +
+ "
\n" +
+ " \n" +
+ ""
+
+ resp, err := dataSource.Parse(context, []byte(content))
+ assert.Equal(t, nil, err)
+ assert.Len(t, resp.ExchangeRates, 0)
+
+ content = "\n" +
+ " \n" +
+ " \n" +
+ " - \n" +
+ " USD\n" +
+ " 28.04.2026\n" +
+ " 0\n" +
+ " 1\n" +
+ "
\n" +
+ " \n" +
+ ""
+
+ resp, err = dataSource.Parse(context, []byte(content))
+ assert.Equal(t, nil, err)
+ assert.Len(t, resp.ExchangeRates, 0)
+}
diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go
index ebce2265..e0ff152a 100644
--- a/pkg/settings/setting.go
+++ b/pkg/settings/setting.go
@@ -128,22 +128,23 @@ const (
// Exchange rates data source types
const (
- BankOfCanadaDataSource string = "bank_of_canada"
- CzechNationalBankDataSource string = "czech_national_bank"
- DanmarksNationalbankDataSource string = "danmarks_national_bank"
- EuroCentralBankDataSource string = "euro_central_bank"
- NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
- CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
- BankOfIsraelDataSource string = "bank_of_israel"
- CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
- 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"
- NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
- CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
- UserCustomExchangeRatesDataSource string = "user_custom"
+ BankOfCanadaDataSource string = "bank_of_canada"
+ CzechNationalBankDataSource string = "czech_national_bank"
+ DanmarksNationalbankDataSource string = "danmarks_national_bank"
+ EuroCentralBankDataSource string = "euro_central_bank"
+ NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
+ CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
+ BankOfIsraelDataSource string = "bank_of_israel"
+ NationalBankOfKazakhstanDataSource string = "national_bank_of_kazakhstan"
+ CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
+ 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"
+ NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
+ CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
+ UserCustomExchangeRatesDataSource string = "user_custom"
)
const (
@@ -1196,6 +1197,7 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio
dataSource == NationalBankOfGeorgiaDataSource ||
dataSource == CentralBankOfHungaryDataSource ||
dataSource == BankOfIsraelDataSource ||
+ dataSource == NationalBankOfKazakhstanDataSource ||
dataSource == CentralBankOfMyanmarDataSource ||
dataSource == NorgesBankDataSource ||
dataSource == NationalBankOfPolandDataSource ||