diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index 92e4d4f2..b7f2c9ad 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -111,7 +111,13 @@ enable_register = true enable_export = true [exchange_rates] -# Exchange rates data source, supports "euro_central_bank", "bank_of_canada", "reserve_bank_of_australia", "czech_national_bank", "national_bank_of_poland" currently +# Exchange rates data source, supports the following types: +# "euro_central_bank" +# "bank_of_canada" +# "reserve_bank_of_australia", +# "czech_national_bank" +# "national_bank_of_poland" +# "monetary_authority_of_singapore" data_source = euro_central_bank # Requesting exchange rates data timeout (0 - 4294967295 milliseconds), default is 10000 (10 seconds) diff --git a/pkg/exchangerates/exchange_rates_datasource_container.go b/pkg/exchangerates/exchange_rates_datasource_container.go index fd26323f..f12e6c49 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.MonetaryAuthorityOfSingaporeDataSource { + Container.Current = &MonetaryAuthorityOfSingaporeDataSource{} + return nil } return errs.ErrInvalidExchangeRatesDataSource diff --git a/pkg/exchangerates/monetary_authority_of_singapore_datasource.go b/pkg/exchangerates/monetary_authority_of_singapore_datasource.go new file mode 100644 index 00000000..a6dece03 --- /dev/null +++ b/pkg/exchangerates/monetary_authority_of_singapore_datasource.go @@ -0,0 +1,179 @@ +package exchangerates + +import ( + "encoding/json" + "math" + "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 monetaryAuthorityOfSingaporeExchangeRateUrl = "https://eservices.mas.gov.sg/api/action/datastore/search.json?resource_id=95932927-c8bc-4e7a-b484-68a66a24edfe&sort=end_of_day+desc&limit=1" +const monetaryAuthorityOfSingaporeExchangeRateReferenceUrl = "https://eservices.mas.gov.sg/Statistics/msb/ExchangeRates.aspx" +const monetaryAuthorityOfSingaporeDataSource = "Monetary Authority of Singapore" +const monetaryAuthorityOfSingaporeBaseCurrency = "SGD" + +const monetaryAuthorityOfSingaporeDataUpdateDateFormat = "2006-01-02 15" +const monetaryAuthorityOfSingaporeDataUpdateDateTimezone = "Asia/Singapore" + +// MonetaryAuthorityOfSingaporeDataSource defines the structure of exchange rates data source of Monetary Authority of Singapore +type MonetaryAuthorityOfSingaporeDataSource struct { + ExchangeRatesDataSource +} + +// MonetaryAuthorityOfSingaporeExchangeRateData represents the whole data from Monetary Authority of Singapore +type MonetaryAuthorityOfSingaporeExchangeRateData struct { + Success bool `json:"success"` + Result *MonetaryAuthorityOfSingaporeResult `json:"result"` +} + +// MonetaryAuthorityOfSingaporeResult represents the actual result from Monetary Authority of Singapore +type MonetaryAuthorityOfSingaporeResult struct { + Records []MonetaryAuthorityOfSingaporeRecord `json:"records"` +} + +// MonetaryAuthorityOfSingaporeRecord represents the record from Monetary Authority of Singapore +type MonetaryAuthorityOfSingaporeRecord map[string]string + +// ToLatestExchangeRateResponse returns a view-object according to original data from Monetary Authority of Singapore +func (e *MonetaryAuthorityOfSingaporeExchangeRateData) ToLatestExchangeRateResponse(c *core.Context) *models.LatestExchangeRateResponse { + if !e.Success { + log.ErrorfWithRequestId(c, "[monetary_authority_of_singapore_datasource.ToLatestExchangeRateResponse] response is not success") + return nil + } + + if e.Result == nil { + log.ErrorfWithRequestId(c, "[monetary_authority_of_singapore_datasource.ToLatestExchangeRateResponse] result is null") + return nil + } + + if len(e.Result.Records) < 1 { + log.ErrorfWithRequestId(c, "[monetary_authority_of_singapore_datasource.ToLatestExchangeRateResponse] records is empty") + return nil + } + + lastDayRecord := e.Result.Records[0] + exchangeRates := make(models.LatestExchangeRateSlice, 0, len(lastDayRecord)) + latestUpdateDate := "" + + for key, value := range lastDayRecord { + if key == "end_of_day" { + latestUpdateDate = value + continue + } + + exchangeRate := e.parseExchangeRateResponse(c, key, value) + + if exchangeRate == nil { + continue + } + + exchangeRates = append(exchangeRates, exchangeRate) + } + + timezone, err := time.LoadLocation(monetaryAuthorityOfSingaporeDataUpdateDateTimezone) + + if err != nil { + log.ErrorfWithRequestId(c, "[monetary_authority_of_singapore_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", monetaryAuthorityOfSingaporeDataUpdateDateTimezone) + return nil + } + + updateDateTime := latestUpdateDate + " 12" // These rates are the average of buying and selling interbank rates quoted around midday in Singapore + updateTime, err := time.ParseInLocation(monetaryAuthorityOfSingaporeDataUpdateDateFormat, updateDateTime, timezone) + + if err != nil { + log.ErrorfWithRequestId(c, "[monetary_authority_of_singapore_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime) + return nil + } + + latestExchangeRateResp := &models.LatestExchangeRateResponse{ + DataSource: monetaryAuthorityOfSingaporeDataSource, + ReferenceUrl: monetaryAuthorityOfSingaporeExchangeRateReferenceUrl, + UpdateTime: updateTime.Unix(), + BaseCurrency: monetaryAuthorityOfSingaporeBaseCurrency, + ExchangeRates: exchangeRates, + } + + return latestExchangeRateResp +} + +func (e *MonetaryAuthorityOfSingaporeExchangeRateData) parseExchangeRateResponse(c *core.Context, key string, value string) *models.LatestExchangeRate { + if !strings.Contains(key, "_") { + return nil + } + + items := strings.Split(key, "_") + + if len(items) < 2 { + return nil + } + + fromCurrencyCode := strings.ToUpper(items[0]) + toCurrencyCode := strings.ToUpper(items[1]) + + if _, exists := validators.AllCurrencyNames[fromCurrencyCode]; !exists { + return nil + } + + if toCurrencyCode != monetaryAuthorityOfSingaporeBaseCurrency { + return nil + } + + rate, err := utils.StringToFloat64(value) + + if err != nil { + log.WarnfWithRequestId(c, "[monetary_authority_of_singapore_datasource.parseExchangeRateResponse] failed to parse rate, rate is %s", value) + return nil + } + + if rate <= 0 { + log.WarnfWithRequestId(c, "[monetary_authority_of_singapore_datasource.parseExchangeRateResponse] rate is invalid, rate is %s", value) + return nil + } + + finalRate := 1 / rate + + if math.IsInf(finalRate, 0) { + return nil + } + + if len(items) == 3 && items[2] == "100" { + finalRate = finalRate * 100 + } + + return &models.LatestExchangeRate{ + Currency: fromCurrencyCode, + Rate: utils.Float64ToString(finalRate), + } +} + +// GetRequestUrls returns the Monetary Authority of Singapore data source urls +func (e *MonetaryAuthorityOfSingaporeDataSource) GetRequestUrls() []string { + return []string{monetaryAuthorityOfSingaporeExchangeRateUrl} +} + +// Parse returns the common response entity according to the Monetary Authority of Singapore data source raw response +func (e *MonetaryAuthorityOfSingaporeDataSource) Parse(c *core.Context, content []byte) (*models.LatestExchangeRateResponse, error) { + monetaryAuthorityOfSingaporeData := &MonetaryAuthorityOfSingaporeExchangeRateData{} + err := json.Unmarshal(content, monetaryAuthorityOfSingaporeData) + + if err != nil { + log.ErrorfWithRequestId(c, "[monetary_authority_of_singapore_datasource.Parse] failed to parse json data, content is %s, because %s", string(content), err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + latestExchangeRateResponse := monetaryAuthorityOfSingaporeData.ToLatestExchangeRateResponse(c) + + if latestExchangeRateResponse == nil { + log.ErrorfWithRequestId(c, "[monetary_authority_of_singapore_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/monetary_authority_of_singapore_datasource_test.go b/pkg/exchangerates/monetary_authority_of_singapore_datasource_test.go new file mode 100644 index 00000000..23eb585c --- /dev/null +++ b/pkg/exchangerates/monetary_authority_of_singapore_datasource_test.go @@ -0,0 +1,206 @@ +package exchangerates + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/gin-gonic/gin" + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +const monetaryAuthorityOfSingaporeMinimumRequiredContent = "{\n" + + " \"success\": true,\n" + + " \"result\": {\n" + + " \"records\": [\n" + + " {\n" + + " \"end_of_day\": \"2023-05-26\",\n" + + " \"usd_sgd\": \"1.3528\",\n" + + " \"cny_sgd_100\": \"19.16\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}" + +func TestMonetaryAuthorityOfSingaporeDataSource_StandardDataExtractBaseCurrency(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(monetaryAuthorityOfSingaporeMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Equal(t, "SGD", actualLatestExchangeRateResponse.BaseCurrency) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_StandardDataExtractExchangeRates(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(monetaryAuthorityOfSingaporeMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "USD", + Rate: "0.7392075694855116", + }) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "CNY", + Rate: "5.219206680584551", + }) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_BlankContent(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + _, err := dataSource.Parse(context, []byte("")) + assert.NotEqual(t, nil, err) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_EmptyJsonObject(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + _, err := dataSource.Parse(context, []byte("{}")) + assert.NotEqual(t, nil, err) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_ResponseSuccessIsFalseObject(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + _, err := dataSource.Parse(context, []byte("{\n"+ + " \"success\": false,\n"+ + " \"result\": {\n"+ + " \"records\": [\n"+ + " {\n"+ + " \"end_of_day\": \"2023-05-26\",\n"+ + " \"usd_sgd\": \"1.3528\",\n"+ + " \"cny_sgd_100\": \"19.16\"\n"+ + " }\n"+ + " ]\n"+ + " }\n"+ + "}")) + assert.NotEqual(t, nil, err) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_NoResultContent(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + _, err := dataSource.Parse(context, []byte("{\n"+ + " \"success\": true\n"+ + "}")) + assert.NotEqual(t, nil, err) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_EmptyRecordContent(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + _, err := dataSource.Parse(context, []byte("{\n"+ + " \"success\": true,\n"+ + " \"result\": {\n"+ + " \"records\": [\n"+ + " ]\n"+ + " }\n"+ + "}")) + assert.NotEqual(t, nil, err) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_TargetCurrencyIsNotBaseCurrency(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("{\n"+ + " \"success\": true,\n"+ + " \"result\": {\n"+ + " \"records\": [\n"+ + " {\n"+ + " \"end_of_day\": \"2023-05-26\",\n"+ + " \"usd_cny\": \"1\""+ + " }\n"+ + " ]\n"+ + " }\n"+ + "}")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_InvalidCurrency(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("{\n"+ + " \"success\": true,\n"+ + " \"result\": {\n"+ + " \"records\": [\n"+ + " {\n"+ + " \"end_of_day\": \"2023-05-26\",\n"+ + " \"xxx_sgd\": \"1.3528\""+ + " }\n"+ + " ]\n"+ + " }\n"+ + "}")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_EmptyRate(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("{\n"+ + " \"success\": true,\n"+ + " \"result\": {\n"+ + " \"records\": [\n"+ + " {\n"+ + " \"end_of_day\": \"2023-05-26\",\n"+ + " \"usd_sgd\": \"\""+ + " }\n"+ + " ]\n"+ + " }\n"+ + "}")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestMonetaryAuthorityOfSingaporeDataSource_InvalidRate(t *testing.T) { + dataSource := &MonetaryAuthorityOfSingaporeDataSource{} + context := &core.Context{ + Context: &gin.Context{}, + } + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("{\n"+ + " \"success\": true,\n"+ + " \"result\": {\n"+ + " \"records\": [\n"+ + " {\n"+ + " \"end_of_day\": \"2023-05-26\",\n"+ + " \"usd_sgd\": null"+ + " }\n"+ + " ]\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 848ac9aa..442291e1 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -64,11 +64,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" + MonetaryAuthorityOfSingaporeDataSource string = "monetary_authority_of_singapore" ) const ( @@ -426,6 +427,8 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio config.ExchangeRatesDataSource = CzechNationalBankDataSource } else if getConfigItemStringValue(configFile, sectionName, "data_source") == NationalBankOfPolandDataSource { config.ExchangeRatesDataSource = NationalBankOfPolandDataSource + } else if getConfigItemStringValue(configFile, sectionName, "data_source") == MonetaryAuthorityOfSingaporeDataSource { + config.ExchangeRatesDataSource = MonetaryAuthorityOfSingaporeDataSource } else { return errs.ErrInvalidExchangeRatesDataSource }