diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini
index 99a1febe..3325d725 100644
--- a/conf/ezbookkeeping.ini
+++ b/conf/ezbookkeeping.ini
@@ -346,6 +346,7 @@ custom_map_tile_server_default_zoom_level = 14
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
# "euro_central_bank": https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html
# "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/
# "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/api/exchange_rates_test.go b/pkg/api/exchange_rates_test.go
index 7568b620..838ef984 100644
--- a/pkg/api/exchange_rates_test.go
+++ b/pkg/api/exchange_rates_test.go
@@ -119,6 +119,22 @@ func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfGeorgiaDataSour
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
}
+func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfHungaryDataSource(t *testing.T) {
+ exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfHungaryDataSource)
+
+ if exchangeRateResponse == nil {
+ return
+ }
+
+ assert.Equal(t, "HUF", exchangeRateResponse.BaseCurrency)
+
+ supportedCurrencyCodes := []string{"AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "EUR",
+ "GBP", "HKD", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD",
+ "PHP", "PLN", "RON", "RSD", "RUB", "SEK", "SGD", "THB", "TRY", "UAH", "USD", "ZAR"}
+
+ checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
+}
+
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfIsraelDataSource(t *testing.T) {
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfIsraelDataSource)
diff --git a/pkg/exchangerates/central_bank_of_hungary_datasource.go b/pkg/exchangerates/central_bank_of_hungary_datasource.go
new file mode 100644
index 00000000..7a2dafb1
--- /dev/null
+++ b/pkg/exchangerates/central_bank_of_hungary_datasource.go
@@ -0,0 +1,203 @@
+package exchangerates
+
+import (
+ "bytes"
+ "encoding/xml"
+ "math"
+ "net/http"
+ "strings"
+ "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 centralBankOfHungaryExchangeRateServiceUrl = "http://www.mnb.hu/arfolyamok.asmx"
+const centralBankOfHungaryExchangeRateServiceCurrentExchangeRatesSoapAction = "http://www.mnb.hu/webservices/MNBArfolyamServiceSoap/GetCurrentExchangeRates"
+const centralBankOfHungaryExchangeRateReferenceUrl = "https://www.mnb.hu/en/arfolyamok"
+const centralBankOfHungaryDataSource = "Magyar Nemzeti Bank"
+const centralBankOfHungaryBaseCurrency = "HUF"
+
+const centralBankOfHungaryUpdateDateFormat = "2006-01-02 15"
+const centralBankOfHungaryUpdateDateTimezone = "Europe/Budapest"
+
+// CentralBankOfHungaryDataSource defines the structure of exchange rates data source of central bank of Hungary
+type CentralBankOfHungaryDataSource struct {
+ ExchangeRatesDataSource
+}
+
+// CentralBankOfHungaryExchangeRateServiceResponse represents the response data of exchange rate service for central bank of Hungary
+type CentralBankOfHungaryExchangeRateServiceResponse struct {
+ XMLName xml.Name `xml:"Envelope"`
+ GetCurrentExchangeRatesResult string `xml:"Body>GetCurrentExchangeRatesResponse>GetCurrentExchangeRatesResult"`
+}
+
+// CentralBankOfHungaryCurrentExchangeRatesResult represents the current exchange rate result data from central bank of Hungary
+type CentralBankOfHungaryCurrentExchangeRatesResult struct {
+ XMLName xml.Name `xml:"MNBCurrentExchangeRates"`
+ AllExchangeRates []*CentralBankOfHungaryExchangeRates `xml:"Day"`
+}
+
+// CentralBankOfHungaryExchangeRates represents the exchange rates data from Danmarks Nationalbank
+type CentralBankOfHungaryExchangeRates struct {
+ Date string `xml:"date,attr"`
+ ExchangeRates []*CentralBankOfHungaryExchangeRate `xml:"Rate"`
+}
+
+// CentralBankOfHungaryExchangeRate represents the exchange rate data from central bank of Hungary
+type CentralBankOfHungaryExchangeRate struct {
+ Currency string `xml:"curr,attr"`
+ Unit string `xml:"unit,attr"`
+ Rate string `xml:",chardata"`
+}
+
+// ToLatestExchangeRateResponse returns a view-object according to original data from central bank of Hungary
+func (e *CentralBankOfHungaryCurrentExchangeRatesResult) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
+ if len(e.AllExchangeRates) < 1 {
+ log.Errorf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
+ return nil
+ }
+
+ latestCentralBankOfHungaryExchangeRate := e.AllExchangeRates[0]
+
+ if len(latestCentralBankOfHungaryExchangeRate.ExchangeRates) < 1 {
+ log.Errorf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
+ return nil
+ }
+
+ exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.AllExchangeRates))
+
+ for i := 0; i < len(latestCentralBankOfHungaryExchangeRate.ExchangeRates); i++ {
+ exchangeRate := latestCentralBankOfHungaryExchangeRate.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(centralBankOfHungaryUpdateDateTimezone)
+
+ if err != nil {
+ log.Errorf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", centralBankOfHungaryUpdateDateTimezone)
+ return nil
+ }
+
+ updateDateTime := latestCentralBankOfHungaryExchangeRate.Date + " 11" // The exchange rates are fixed at 11 am.
+ updateTime, err := time.ParseInLocation(centralBankOfHungaryUpdateDateFormat, updateDateTime, timezone)
+
+ if err != nil {
+ log.Errorf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
+ return nil
+ }
+
+ latestExchangeRateResp := &models.LatestExchangeRateResponse{
+ DataSource: centralBankOfHungaryDataSource,
+ ReferenceUrl: centralBankOfHungaryExchangeRateReferenceUrl,
+ UpdateTime: updateTime.Unix(),
+ BaseCurrency: centralBankOfHungaryBaseCurrency,
+ ExchangeRates: exchangeRates,
+ }
+
+ return latestExchangeRateResp
+}
+
+// ToLatestExchangeRate returns a data pair according to original data from central bank of Hungary
+func (e *CentralBankOfHungaryExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
+ rate, err := utils.StringToFloat64(strings.ReplaceAll(e.Rate, ",", "."))
+
+ if err != nil {
+ log.Warnf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
+ return nil
+ }
+
+ if rate <= 0 {
+ log.Warnf(c, "[central_bank_of_hungary_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, "[central_bank_of_hungary_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit is %s", e.Currency, e.Unit)
+ return nil
+ }
+
+ finalRate := unit / rate
+
+ if math.IsInf(finalRate, 0) {
+ return nil
+ }
+
+ return &models.LatestExchangeRate{
+ Currency: e.Currency,
+ Rate: utils.Float64ToString(finalRate),
+ }
+}
+
+// BuildRequests returns the central bank of Hungary exchange rates http requests
+func (e *CentralBankOfHungaryDataSource) BuildRequests() ([]*http.Request, error) {
+ req, err := http.NewRequest("POST", centralBankOfHungaryExchangeRateServiceUrl, bytes.NewReader([]byte(
+ ""+
+ ""+
+ ""+
+ ""+
+ "")))
+
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "text/xml; charset=utf-8")
+ req.Header.Set("SOAPAction", centralBankOfHungaryExchangeRateServiceCurrentExchangeRatesSoapAction)
+
+ return []*http.Request{req}, nil
+}
+
+// Parse returns the common response entity according to the central bank of Hungary data source raw response
+func (e *CentralBankOfHungaryDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
+ responseXmlDecoder := xml.NewDecoder(bytes.NewReader(content))
+
+ centralBankOfHungaryServiceResponse := &CentralBankOfHungaryExchangeRateServiceResponse{}
+ err := responseXmlDecoder.Decode(centralBankOfHungaryServiceResponse)
+
+ if err != nil {
+ log.Errorf(c, "[central_bank_of_hungary_datasource.Parse] failed to parse service response xml data, content is %s, because %s", string(content), err.Error())
+ return nil, errs.ErrFailedToRequestRemoteApi
+ }
+
+ if len(centralBankOfHungaryServiceResponse.GetCurrentExchangeRatesResult) < 1 {
+ log.Errorf(c, "[central_bank_of_hungary_datasource.Parse] exchange rates response is empty")
+ return nil, errs.ErrFailedToRequestRemoteApi
+ }
+
+ resultXmlDecoder := xml.NewDecoder(strings.NewReader(centralBankOfHungaryServiceResponse.GetCurrentExchangeRatesResult))
+
+ centralBankOfHungaryExchangeRatesResult := &CentralBankOfHungaryCurrentExchangeRatesResult{}
+ err = resultXmlDecoder.Decode(centralBankOfHungaryExchangeRatesResult)
+
+ if err != nil {
+ log.Errorf(c, "[central_bank_of_hungary_datasource.Parse] failed to parse exchange rates response xml data, content is %s, because %s", string(content), err.Error())
+ return nil, errs.ErrFailedToRequestRemoteApi
+ }
+
+ latestExchangeRateResponse := centralBankOfHungaryExchangeRatesResult.ToLatestExchangeRateResponse(c)
+
+ if latestExchangeRateResponse == nil {
+ log.Errorf(c, "[central_bank_of_hungary_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/central_bank_of_hungary_datasource_test.go b/pkg/exchangerates/central_bank_of_hungary_datasource_test.go
new file mode 100644
index 00000000..bb1a15a9
--- /dev/null
+++ b/pkg/exchangerates/central_bank_of_hungary_datasource_test.go
@@ -0,0 +1,248 @@
+package exchangerates
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/mayswind/ezbookkeeping/pkg/core"
+ "github.com/mayswind/ezbookkeeping/pkg/models"
+)
+
+const centralBankOfHungaryDataSourceMinimumRequiredContent = "" +
+ "" +
+ "" +
+ "" +
+ "<MNBCurrentExchangeRates>" +
+ "<Day date=\"2024-11-15\">" +
+ "<Rate unit=\"100\" curr=\"JPY\">247,46</Rate>" +
+ "<Rate unit=\"1\" curr=\"USD\">384,48</Rate>" +
+ "</Day>" +
+ "</MNBCurrentExchangeRates>" +
+ "" +
+ "" +
+ "" +
+ ""
+
+func TestCentralBankOfHungaryDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfHungaryDataSourceMinimumRequiredContent))
+ assert.Equal(t, nil, err)
+ assert.Equal(t, "HUF", actualLatestExchangeRateResponse.BaseCurrency)
+}
+
+func TestCentralBankOfHungaryDataSource_StandardDataExtractUpdateTime(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfHungaryDataSourceMinimumRequiredContent))
+ assert.Equal(t, nil, err)
+ assert.Equal(t, int64(1731664800), actualLatestExchangeRateResponse.UpdateTime)
+}
+
+func TestCentralBankOfHungaryDataSource_StandardDataExtractExchangeRates(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfHungaryDataSourceMinimumRequiredContent))
+ assert.Equal(t, nil, err)
+ assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
+ Currency: "JPY",
+ Rate: "0.4041057140547967",
+ })
+ assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
+ Currency: "USD",
+ Rate: "0.002600915522263837",
+ })
+}
+
+func TestCentralBankOfHungaryDataSource_BlankContent(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ _, err := dataSource.Parse(context, []byte(""))
+ assert.NotEqual(t, nil, err)
+}
+
+func TestCentralBankOfHungaryDataSource_MissingSoapBody(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ _, err := dataSource.Parse(context, []byte(""+
+ ""))
+ assert.NotEqual(t, nil, err)
+}
+
+func TestCentralBankOfHungaryDataSource_MissingGetCurrentExchangeRatesResponse(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ _, err := dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""))
+ assert.NotEqual(t, nil, err)
+}
+
+func TestCentralBankOfHungaryDataSource_MissingGetCurrentExchangeRatesResult(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ _, err := dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ ""+
+ ""))
+ assert.NotEqual(t, nil, err)
+}
+
+func TestCentralBankOfHungaryDataSource_EmptyGetCurrentExchangeRatesResult(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ _, err := dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ ""+
+ ""+
+ ""+
+ ""))
+ assert.NotEqual(t, nil, err)
+}
+
+func TestCentralBankOfHungaryDataSource_InvalidGetCurrentExchangeRatesResult(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ _, err := dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ "<MNBCurrentExchangeRates>"+
+ "<Day date=\"2024-11-15\">"+
+ ""+
+ ""+
+ ""+
+ ""))
+ assert.NotEqual(t, nil, err)
+}
+
+func TestCentralBankOfHungaryDataSource_InvalidCurrency(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ "<MNBCurrentExchangeRates>"+
+ "<Day date=\"2024-11-15\">"+
+ "<Rate unit=\"1\" curr=\"XXX\">1</Rate>"+
+ "</Day>"+
+ "</MNBCurrentExchangeRates>"+
+ ""+
+ ""+
+ ""+
+ ""))
+ assert.Equal(t, nil, err)
+ assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
+}
+
+func TestCentralBankOfHungaryDataSource_EmptyRate(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ "<MNBCurrentExchangeRates>"+
+ "<Day date=\"2024-11-15\">"+
+ "<Rate unit=\"1\" curr=\"USD\"></Rate>"+
+ "</Day>"+
+ "</MNBCurrentExchangeRates>"+
+ ""+
+ ""+
+ ""+
+ ""))
+ assert.Equal(t, nil, err)
+ assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
+}
+
+func TestCentralBankOfHungaryDataSource_InvalidRate(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ "<MNBCurrentExchangeRates>"+
+ "<Day date=\"2024-11-15\">"+
+ "<Rate unit=\"1\" curr=\"USD\">null</Rate>"+
+ "</Day>"+
+ "</MNBCurrentExchangeRates>"+
+ ""+
+ ""+
+ ""+
+ ""))
+ assert.Equal(t, nil, err)
+ assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
+
+ actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ "<MNBCurrentExchangeRates>"+
+ "<Day date=\"2024-11-15\">"+
+ "<Rate unit=\"1\" curr=\"USD\">0</Rate>"+
+ "</Day>"+
+ "</MNBCurrentExchangeRates>"+
+ ""+
+ ""+
+ ""+
+ ""))
+ assert.Equal(t, nil, err)
+ assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
+}
+
+func TestCentralBankOfHungaryDataSource_InvalidUnit(t *testing.T) {
+ dataSource := &CentralBankOfHungaryDataSource{}
+ context := core.NewNullContext()
+
+ actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ "<MNBCurrentExchangeRates>"+
+ "<Day date=\"2024-11-15\">"+
+ "<Rate unit=\"null\" curr=\"USD\">384,48</Rate>"+
+ "</Day>"+
+ "</MNBCurrentExchangeRates>"+
+ ""+
+ ""+
+ ""+
+ ""))
+ assert.Equal(t, nil, err)
+ assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
+
+ actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte(""+
+ ""+
+ ""+
+ ""+
+ "<MNBCurrentExchangeRates>"+
+ "<Day date=\"2024-11-15\">"+
+ "<Rate unit=\"\" curr=\"USD\">384,48</Rate>"+
+ "</Day>"+
+ "</MNBCurrentExchangeRates>"+
+ ""+
+ ""+
+ ""+
+ ""))
+ 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 fc1ba6fe..94d7dc27 100644
--- a/pkg/exchangerates/exchange_rates_datasource_container.go
+++ b/pkg/exchangerates/exchange_rates_datasource_container.go
@@ -35,6 +35,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error {
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
Container.Current = &NationalBankOfGeorgiaDataSource{}
return nil
+ } else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
+ Container.Current = &CentralBankOfHungaryDataSource{}
+ return nil
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
Container.Current = &BankOfIsraelDataSource{}
return nil
diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go
index 93ade7aa..84bc94a0 100644
--- a/pkg/settings/setting.go
+++ b/pkg/settings/setting.go
@@ -106,6 +106,7 @@ const (
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"
NorgesBankDataSource string = "norges_bank"
NationalBankOfPolandDataSource string = "national_bank_of_poland"
@@ -891,6 +892,7 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio
dataSource == DanmarksNationalbankDataSource ||
dataSource == EuroCentralBankDataSource ||
dataSource == NationalBankOfGeorgiaDataSource ||
+ dataSource == CentralBankOfHungaryDataSource ||
dataSource == BankOfIsraelDataSource ||
dataSource == NorgesBankDataSource ||
dataSource == NationalBankOfPolandDataSource ||