diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index 12adef01..ddd14edc 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -345,6 +345,7 @@ custom_map_tile_server_default_zoom_level = 14 # "reserve_bank_of_australia": https://www.rba.gov.au/statistics/frequency/exchange-rates.html # "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/ # "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/ +# "bank_of_israel": https://www.boi.org.il/en/economic-roles/financial-markets/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/exchangerates/bank_of_israel_datasource.go b/pkg/exchangerates/bank_of_israel_datasource.go new file mode 100644 index 00000000..d3aa9fc6 --- /dev/null +++ b/pkg/exchangerates/bank_of_israel_datasource.go @@ -0,0 +1,143 @@ +package exchangerates + +import ( + "encoding/xml" + "math" + "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 bankOfIsraelExchangeRateUrl = "https://boi.org.il/PublicApi/GetExchangeRates?asXml=true" +const bankOfIsraelExchangeRateReferenceUrl = "https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/" +const bankOfIsraelDataSource = "Bank of Israel" +const bankOfIsraelBaseCurrency = "ILS" + +const bankOfIsraelDataUpdateDateFormat = "2006-01-02T15:04:05.9999999Z" + +// BankOfIsraelDataSource defines the structure of exchange rates data source of bank of Israel +type BankOfIsraelDataSource struct { + ExchangeRatesDataSource +} + +// bankOfIsraelExchangeRateData represents the whole data from bank of Israel +type bankOfIsraelExchangeRateData struct { + XMLName xml.Name `xml:"ExchangeRatesResponseCollectioDTO"` + AllExchangeRates []*bankOfIsraelExchangeRate `xml:"ExchangeRates>ExchangeRateResponseDTO"` +} + +// bankOfIsraelExchangeRate represents the exchange rate data from bank of Israel +type bankOfIsraelExchangeRate struct { + Currency string `xml:"Key"` + Rate string `xml:"CurrentExchangeRate"` + LastUpdate string `xml:"LastUpdate"` + Unit string `xml:"Unit"` +} + +// ToLatestExchangeRateResponse returns a view-object according to original data from bank of Israel +func (e *bankOfIsraelExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse { + if len(e.AllExchangeRates) < 1 { + log.Errorf(c, "[bank_of_israel_datasource.ToLatestExchangeRateResponse] all exchange rates is empty") + return nil + } + + latestUpdateDate := "" + exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.AllExchangeRates)) + + for i := 0; i < len(e.AllExchangeRates); i++ { + exchangeRate := e.AllExchangeRates[i] + + if latestUpdateDate == "" { + latestUpdateDate = exchangeRate.LastUpdate + } + + if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists { + continue + } + + rate, err := utils.StringToFloat64(exchangeRate.Rate) + + if err != nil { + log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRateResponse] failed to parse rate, rate is %s", exchangeRate.Rate) + continue + } + + if rate <= 0 { + log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRateResponse] rate is invalid, rate is %s", exchangeRate.Rate) + continue + } + + unit, err := utils.StringToFloat64(exchangeRate.Unit) + + if err != nil { + log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRateResponse] failed to parse unit, unit is %s", exchangeRate.Unit) + continue + } + + finalRate := unit / rate + + if math.IsInf(finalRate, 0) { + continue + } + + exchangeRates = append(exchangeRates, &models.LatestExchangeRate{ + Currency: exchangeRate.Currency, + Rate: utils.Float64ToString(finalRate), + }) + } + + updateTime, err := time.Parse(bankOfIsraelDataUpdateDateFormat, latestUpdateDate) + + if err != nil { + log.Errorf(c, "[bank_of_israel_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", latestUpdateDate) + return nil + } + + latestExchangeRateResp := &models.LatestExchangeRateResponse{ + DataSource: bankOfIsraelDataSource, + ReferenceUrl: bankOfIsraelExchangeRateReferenceUrl, + UpdateTime: updateTime.Unix(), + BaseCurrency: bankOfIsraelBaseCurrency, + ExchangeRates: exchangeRates, + } + + return latestExchangeRateResp +} + +// ToLatestExchangeRate returns a data pair according to original data from bank of Israel +func (e *bankOfIsraelExchangeRate) ToLatestExchangeRate() *models.LatestExchangeRate { + return &models.LatestExchangeRate{ + Currency: e.Currency, + Rate: e.Rate, + } +} + +// GetRequestUrls returns the bank of Israel data source urls +func (e *BankOfIsraelDataSource) GetRequestUrls() []string { + return []string{bankOfIsraelExchangeRateUrl} +} + +// Parse returns the common response entity according to the bank of Israel data source raw response +func (e *BankOfIsraelDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) { + bankOfIsraelData := &bankOfIsraelExchangeRateData{} + err := xml.Unmarshal(content, bankOfIsraelData) + + if err != nil { + log.Errorf(c, "[bank_of_israel_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + latestExchangeRateResponse := bankOfIsraelData.ToLatestExchangeRateResponse(c) + + if latestExchangeRateResponse == nil { + log.Errorf(c, "[bank_of_israel_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_israel_datasource_test.go b/pkg/exchangerates/bank_of_israel_datasource_test.go new file mode 100644 index 00000000..6cb243ad --- /dev/null +++ b/pkg/exchangerates/bank_of_israel_datasource_test.go @@ -0,0 +1,171 @@ +package exchangerates + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +const bankOfIsraelMinimumRequiredContent = "" + + "\n" + + " \n" + + " \n" + + " 3.733\n" + + " USD\n" + + " 2024-11-11T13:26:05.6590204Z\n" + + " 1\n" + + " \n" + + " \n" + + " 2.4287\n" + + " JPY\n" + + " 2024-11-11T13:26:05.6590204Z\n" + + " 100\n" + + " \n" + + " \n" + + "" + +func TestBankOfIsraelDataSource_StandardDataExtractBaseCurrency(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfIsraelMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Equal(t, "ILS", actualLatestExchangeRateResponse.BaseCurrency) +} + +func TestBankOfIsraelDataSource_StandardDataExtractExchangeRates(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfIsraelMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "USD", + Rate: "0.2678810608090008", + }) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "JPY", + Rate: "41.17429077284144", + }) +} + +func TestBankOfIsraelDataSource_BlankContent(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("")) + assert.NotEqual(t, nil, err) +} + +func TestBankOfIsraelDataSource_EmptyExchangeRatesResponseCollectioDTO(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("\n"+ + "")) + assert.NotEqual(t, nil, err) +} + +func TestBankOfIsraelDataSource_InvalidCurrency(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + " \n"+ + " \n"+ + " 1\n"+ + " XXX\n"+ + " 2024-11-11T13:26:05.6590204Z\n"+ + " 1\n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestBankOfIsraelDataSource_EmptyRate(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + " \n"+ + " \n"+ + " USD\n"+ + " 2024-11-11T13:26:05.6590204Z\n"+ + " 1\n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestBankOfIsraelDataSource_InvalidRate(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + " \n"+ + " \n"+ + " null\n"+ + " USD\n"+ + " 2024-11-11T13:26:05.6590204Z\n"+ + " 1\n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) + + actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("\n"+ + " \n"+ + " \n"+ + " 0\n"+ + " USD\n"+ + " 2024-11-11T13:26:05.6590204Z\n"+ + " 1\n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestBankOfIsraelDataSource_EmptyUnit(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + " \n"+ + " \n"+ + " 1\n"+ + " USD\n"+ + " 2024-11-11T13:26:05.6590204Z\n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestBankOfIsraelDataSource_InvalidUnit(t *testing.T) { + dataSource := &BankOfIsraelDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + " \n"+ + " \n"+ + " 1\n"+ + " USD\n"+ + " 2024-11-11T13:26:05.6590204Z\n"+ + " null\n"+ + " \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 c518832c..6d42092d 100644 --- a/pkg/exchangerates/exchange_rates_datasource_container.go +++ b/pkg/exchangerates/exchange_rates_datasource_container.go @@ -32,6 +32,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error { } else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource { Container.Current = &NationalBankOfPolandDataSource{} return nil + } else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource { + Container.Current = &BankOfIsraelDataSource{} + return nil } else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource { Container.Current = &InternationalMonetaryFundDataSource{} return nil diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index e3ac984f..ac562a45 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -105,6 +105,7 @@ const ( ReserveBankOfAustraliaDataSource string = "reserve_bank_of_australia" CzechNationalBankDataSource string = "czech_national_bank" NationalBankOfPolandDataSource string = "national_bank_of_poland" + BankOfIsraelDataSource string = "bank_of_israel" InternationalMonetaryFundDataSource string = "international_monetary_fund" ) @@ -887,6 +888,8 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio config.ExchangeRatesDataSource = CzechNationalBankDataSource } else if dataSource == NationalBankOfPolandDataSource { config.ExchangeRatesDataSource = NationalBankOfPolandDataSource + } else if dataSource == BankOfIsraelDataSource { + config.ExchangeRatesDataSource = BankOfIsraelDataSource } else if dataSource == InternationalMonetaryFundDataSource { config.ExchangeRatesDataSource = InternationalMonetaryFundDataSource } else {