diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index ab072aea..f72af87d 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -306,6 +306,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/ +# "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx data_source = euro_central_bank # Requesting exchange rates data timeout (0 - 4294967295 milliseconds) diff --git a/pkg/exchangerates/exchange_rates_datasource_container.go b/pkg/exchangerates/exchange_rates_datasource_container.go index fd26323f..c518832c 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.InternationalMonetaryFundDataSource { + Container.Current = &InternationalMonetaryFundDataSource{} + return nil } return errs.ErrInvalidExchangeRatesDataSource diff --git a/pkg/exchangerates/international_monetary_fund_datasource.go b/pkg/exchangerates/international_monetary_fund_datasource.go new file mode 100644 index 00000000..b74e6441 --- /dev/null +++ b/pkg/exchangerates/international_monetary_fund_datasource.go @@ -0,0 +1,231 @@ +package exchangerates + +import ( + "strings" + "time" + + orderedmap "github.com/wk8/go-ordered-map/v2" + + "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 internationalMonetaryFundExchangeRateUrl = "https://www.imf.org/external/np/fin/data/rms_five.aspx?tsvflag=Y" +const internationalMonetaryFundExchangeRateReferenceUrl = "https://www.imf.org/external/np/fin/data/param_rms_mth.aspx" +const internationalMonetaryFundDataSource = "International Monetary Fund" +const internationalMonetaryFundBaseCurrency = "USD" + +const internationalMonetaryFundDataUpdateDateFormat = "January 02, 2006 15:04" +const internationalMonetaryFundDataUpdateDateTimezone = "America/New_York" + +var internationalMonetaryFundCurrencyNameCodeMap map[string]string + +// InternationalMonetaryFundDataSource defines the structure of exchange rates data source of international monetary fund +type InternationalMonetaryFundDataSource struct { + ExchangeRatesDataSource +} + +func init() { + internationalMonetaryFundCurrencyNameCodeMap = make(map[string]string, 38) + internationalMonetaryFundCurrencyNameCodeMap["Chinese yuan"] = "CNY" + internationalMonetaryFundCurrencyNameCodeMap["Euro"] = "EUR" + internationalMonetaryFundCurrencyNameCodeMap["Japanese yen"] = "JPY" + internationalMonetaryFundCurrencyNameCodeMap["U.K. pound"] = "GBP" + internationalMonetaryFundCurrencyNameCodeMap["U.S. dollar"] = "USD" + internationalMonetaryFundCurrencyNameCodeMap["Algerian dinar"] = "DZD" + internationalMonetaryFundCurrencyNameCodeMap["Australian dollar"] = "AUD" + internationalMonetaryFundCurrencyNameCodeMap["Botswana pula"] = "BWP" + internationalMonetaryFundCurrencyNameCodeMap["Brazilian real"] = "BRL" + internationalMonetaryFundCurrencyNameCodeMap["Brunei dollar"] = "BND" + internationalMonetaryFundCurrencyNameCodeMap["Canadian dollar"] = "CAD" + internationalMonetaryFundCurrencyNameCodeMap["Chilean peso"] = "CLP" + internationalMonetaryFundCurrencyNameCodeMap["Czech koruna"] = "CZK" + internationalMonetaryFundCurrencyNameCodeMap["Danish krone"] = "DKK" + internationalMonetaryFundCurrencyNameCodeMap["Indian rupee"] = "INR" + internationalMonetaryFundCurrencyNameCodeMap["Israeli New Shekel"] = "ILS" + internationalMonetaryFundCurrencyNameCodeMap["Korean won"] = "KRW" + internationalMonetaryFundCurrencyNameCodeMap["Kuwaiti dinar"] = "KWD" + internationalMonetaryFundCurrencyNameCodeMap["Malaysian ringgit"] = "MYR" + internationalMonetaryFundCurrencyNameCodeMap["Mauritian rupee"] = "MUR" + internationalMonetaryFundCurrencyNameCodeMap["Mexican peso"] = "MXN" + internationalMonetaryFundCurrencyNameCodeMap["New Zealand dollar"] = "NZD" + internationalMonetaryFundCurrencyNameCodeMap["Norwegian krone"] = "NOK" + internationalMonetaryFundCurrencyNameCodeMap["Omani rial"] = "OMR" + internationalMonetaryFundCurrencyNameCodeMap["Peruvian sol"] = "PEN" + internationalMonetaryFundCurrencyNameCodeMap["Philippine peso"] = "PHP" + internationalMonetaryFundCurrencyNameCodeMap["Polish zloty"] = "PLN" + internationalMonetaryFundCurrencyNameCodeMap["Qatari riyal"] = "QAR" + internationalMonetaryFundCurrencyNameCodeMap["Russian ruble"] = "RUB" + internationalMonetaryFundCurrencyNameCodeMap["Saudi Arabian riyal"] = "SAR" + internationalMonetaryFundCurrencyNameCodeMap["Singapore dollar"] = "SGD" + internationalMonetaryFundCurrencyNameCodeMap["South African rand"] = "ZAR" + internationalMonetaryFundCurrencyNameCodeMap["Swedish krona"] = "SEK" + internationalMonetaryFundCurrencyNameCodeMap["Swiss franc"] = "CHF" + internationalMonetaryFundCurrencyNameCodeMap["Thai baht"] = "THB" + internationalMonetaryFundCurrencyNameCodeMap["Trinidadian dollar"] = "TTD" + internationalMonetaryFundCurrencyNameCodeMap["U.A.E. dirham"] = "AED" + internationalMonetaryFundCurrencyNameCodeMap["Uruguayan peso"] = "UYU" +} + +// GetRequestUrls returns the international monetary fund data source urls +func (e *InternationalMonetaryFundDataSource) GetRequestUrls() []string { + return []string{internationalMonetaryFundExchangeRateUrl} +} + +// Parse returns the common response entity according to the international monetary fund data source raw response +func (e *InternationalMonetaryFundDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) { + lines := strings.Split(string(content), "\n") + + if len(lines) < 1 { + log.Errorf(c, "[international_monetary_fund_datasource.Parse] content is invalid, content is %s", string(content)) + return nil, errs.ErrFailedToRequestRemoteApi + } + + exchangeRatesToSDR := orderedmap.New[string, float64]() + latestUpdateDate := "" + + findSDRsPerCurrencyUnitLine := false + findExchangeRateDataHeader := false + + for i := 0; i < len(lines); i++ { + line := lines[i] + + if line == "" { + continue + } + + line = strings.ReplaceAll(line, "\r", "") + + if strings.Index(line, "Currency units per SDR") == 0 { + break + } + + if strings.Index(line, "SDRs per Currency unit") == 0 { + findSDRsPerCurrencyUnitLine = true + continue + } + + if findExchangeRateDataHeader { + items := strings.Split(line, "\t") + + if len(items) != 6 { + continue + } + + currencyCode, exchangeRate := e.parseExchangeRate(c, line, items) + + if currencyCode != nil && exchangeRate != nil { + exchangeRatesToSDR.Set(*currencyCode, *exchangeRate) + } + + continue + } + + if findSDRsPerCurrencyUnitLine { + items := strings.Split(line, "\t") + + if len(items) != 6 { + continue + } + + if items[0] == "Currency" { + findExchangeRateDataHeader = true + latestUpdateDate = items[1] + continue + } + } + } + + if latestUpdateDate == "" { + log.Errorf(c, "[international_monetary_fund_datasource.Parse] latest update date is empty") + return nil, errs.ErrFailedToRequestRemoteApi + } + + if exchangeRatesToSDR.Len() < 1 { + log.Errorf(c, "[international_monetary_fund_datasource.Parse] exchange rates date is empty") + return nil, errs.ErrFailedToRequestRemoteApi + } + + defaultCurrencyExchangeRateToSDR, exists := exchangeRatesToSDR.Get(internationalMonetaryFundBaseCurrency) + + if !exists { + log.Errorf(c, "[international_monetary_fund_datasource.Parse] exchange rates date does not have default currency \"%s\"", internationalMonetaryFundBaseCurrency) + return nil, errs.ErrFailedToRequestRemoteApi + } + + exchangeRates := make(models.LatestExchangeRateSlice, 0, exchangeRatesToSDR.Len()) + + for pair := exchangeRatesToSDR.Oldest(); pair != nil; pair = pair.Next() { + exchangeRates = append(exchangeRates, &models.LatestExchangeRate{ + Currency: pair.Key, + Rate: utils.Float64ToString(defaultCurrencyExchangeRateToSDR / pair.Value), + }) + } + + timezone, err := time.LoadLocation(internationalMonetaryFundDataUpdateDateTimezone) + + if err != nil { + log.Errorf(c, "[international_monetary_fund_datasource.Parse] failed to get timezone, timezone name is %s", internationalMonetaryFundDataUpdateDateTimezone) + return nil, errs.ErrFailedToRequestRemoteApi + } + + updateDateTime := latestUpdateDate + " 11:00" // The IMF posts Representative and SDR exchange rates every 20 minutes from 11:00 AM to 6:00 PM U.S. EST Monday to Friday except for these holidays + updateTime, err := time.ParseInLocation(internationalMonetaryFundDataUpdateDateFormat, updateDateTime, timezone) + + if err != nil { + log.Errorf(c, "[international_monetary_fund_datasource.Parse] failed to parse update date, datetime is %s", updateDateTime) + return nil, errs.ErrFailedToRequestRemoteApi + } + + latestExchangeRateResp := &models.LatestExchangeRateResponse{ + DataSource: internationalMonetaryFundDataSource, + ReferenceUrl: internationalMonetaryFundExchangeRateReferenceUrl, + UpdateTime: updateTime.Unix(), + BaseCurrency: internationalMonetaryFundBaseCurrency, + ExchangeRates: exchangeRates, + } + + return latestExchangeRateResp, nil +} + +func (e *InternationalMonetaryFundDataSource) parseExchangeRate(c core.Context, line string, lineItems []string) (*string, *float64) { + currencyCode, exists := internationalMonetaryFundCurrencyNameCodeMap[lineItems[0]] + + if !exists { + log.Warnf(c, "[international_monetary_fund_datasource.parseExchangeRate] unknown currency name %s, line is %s", lineItems[0], line) + return nil, nil + } + + if _, exists := validators.AllCurrencyNames[currencyCode]; !exists { + return nil, nil + } + + for i := 1; i < 6; i++ { + item := lineItems[i] + + if item == "" { + continue + } + + rate, err := utils.StringToFloat64(item) + + if err != nil { + log.Warnf(c, "[international_monetary_fund_datasource.parseExchangeRate] failed to parse rate, line is %s", line) + return nil, nil + } + + if rate <= 0 { + log.Warnf(c, "[international_monetary_fund_datasource.parseExchangeRate] rate is invalid, line is %s", line) + return nil, nil + } + + return ¤cyCode, &rate + } + + log.Warnf(c, "[international_monetary_fund_datasource.parseExchangeRate] no exchange rate data exists for currency \"%s\", line is %s", currencyCode, line) + return nil, nil +} diff --git a/pkg/exchangerates/international_monetary_fund_datasource_test.go b/pkg/exchangerates/international_monetary_fund_datasource_test.go new file mode 100644 index 00000000..9ce9a3af --- /dev/null +++ b/pkg/exchangerates/international_monetary_fund_datasource_test.go @@ -0,0 +1 @@ +package exchangerates diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index 3dca3c8c..a0f8acf4 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -100,11 +100,12 @@ const ( // Exchange rates data source types const ( - EuroCentralBankDataSource string = "euro_central_bank" - BankOfCanadaDataSource string = "bank_of_canada" - ReserveBankOfAustraliaDataSource string = "reserve_bank_of_australia" - CzechNationalBankDataSource string = "czech_national_bank" - NationalBankOfPolandDataSource string = "national_bank_of_poland" + EuroCentralBankDataSource string = "euro_central_bank" + BankOfCanadaDataSource string = "bank_of_canada" + ReserveBankOfAustraliaDataSource string = "reserve_bank_of_australia" + CzechNationalBankDataSource string = "czech_national_bank" + NationalBankOfPolandDataSource string = "national_bank_of_poland" + InternationalMonetaryFundDataSource string = "international_monetary_fund" ) const ( @@ -247,7 +248,7 @@ type Config struct { DuplicateSubmissionsIntervalDuration time.Duration // Cron - EnableRemoveExpiredTokens bool + EnableRemoveExpiredTokens bool EnableCreateScheduledTransaction bool // Secret @@ -846,6 +847,8 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio config.ExchangeRatesDataSource = CzechNationalBankDataSource } else if dataSource == NationalBankOfPolandDataSource { config.ExchangeRatesDataSource = NationalBankOfPolandDataSource + } else if dataSource == InternationalMonetaryFundDataSource { + config.ExchangeRatesDataSource = InternationalMonetaryFundDataSource } else { return errs.ErrInvalidExchangeRatesDataSource }