This commit is contained in:
MaysWind
2025-04-20 10:29:26 +08:00
6 changed files with 339 additions and 1 deletions
+1
View File
@@ -362,6 +362,7 @@ custom_map_tile_server_default_zoom_level = 14
# "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates # "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates
# "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/ # "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/
# "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx # "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx
# "national_bank_of_ukraine": https://bank.gov.ua/ua/markets/exchangerates
data_source = euro_central_bank data_source = euro_central_bank
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds) # Requesting exchange rates data timeout (0 - 4294967295 milliseconds)
+18
View File
@@ -292,6 +292,24 @@ func TestExchangeRatesApiLatestExchangeRateHandler_InternationalMonetaryFundData
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates) checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
} }
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfUkraineDataSource(t *testing.T) {
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfUkraineDataSource)
if exchangeRateResponse == nil {
return
}
assert.Equal(t, "UAH", exchangeRateResponse.BaseCurrency)
supportedCurrencyCodes := []string{
"AED", "AUD", "AZN", "BDT", "BGN", "CAD", "CHF", "CNY", "CZK", "DKK",
"DZD", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "ILS", "INR",
"JPY", "KRW", "KZT", "LBP", "MDL", "MXN", "MYR", "NOK", "NZD", "PLN",
"RON", "RSD", "SAR", "SEK", "SGD", "THB", "TND", "TRY", "USD", "VND", "ZAR"}
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
}
func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *models.LatestExchangeRateResponse { func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *models.LatestExchangeRateResponse {
config := &settings.Config{ config := &settings.Config{
ExchangeRatesDataSource: dataSourceType, ExchangeRatesDataSource: dataSourceType,
@@ -65,6 +65,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error {
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource { } else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
Container.Current = &InternationalMonetaryFundDataSource{} Container.Current = &InternationalMonetaryFundDataSource{}
return nil return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource {
Container.Current = &NationalBankOfUkraineDataSource{}
return nil
} }
return errs.ErrInvalidExchangeRatesDataSource return errs.ErrInvalidExchangeRatesDataSource
@@ -0,0 +1,138 @@
package exchangerates
import (
"encoding/json"
"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 nationalBankOfUkraineExchangeRateUrl = "https://bank.gov.ua/NBU_Exchange/exchange?json"
const nationalBankOfUkraineExchangeRateReferenceUrl = "https://bank.gov.ua/ua/markets/exchangerates"
const nationalBankOfUkraineDataSource = "Національний банк України"
const nationalBankOfUkraineBaseCurrency = "UAH"
const nationalBankOfUkraineUpdateDateFormat = "02.01.2006"
// NationalBankOfUkraineDataSource defines the structure of exchange rates data source of National Bank of Ukraine
type NationalBankOfUkraineDataSource struct {
ExchangeRatesDataSource
}
// NationalBankOfUkraineExchangeRates represents the exchange rates data from National Bank of Ukraine
type NationalBankOfUkraineExchangeRates []NaionalBankOfUkraineExchangeRate
// NaionalBankOfUkraineExchangeRate represents the exchange rate data from National Bank of Ukraine
type NaionalBankOfUkraineExchangeRate struct {
Currency string `json:"CurrencyCodeL"`
Quantity float64 `json:"Units"`
Rate float64 `json:"Amount"`
Date string `json:"StartDate"`
}
// ToLatestExchangeRateResponse returns a view-object according to original data from National Bank of Ukraine
func (e *NationalBankOfUkraineExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(*e))
latestUpdateTime := int64(0)
for _, exchangeRate := range *e {
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
continue
}
updateTime, err := time.Parse(nationalBankOfUkraineUpdateDateFormat, exchangeRate.Date)
if err != nil {
log.Errorf(c, "[national_bank_of_ukraine_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Date)
return nil
}
if updateTime.Unix() > latestUpdateTime {
latestUpdateTime = updateTime.Unix()
}
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
if finalExchangeRate == nil {
continue
}
exchangeRates = append(exchangeRates, finalExchangeRate)
}
latestExchangeRateResp := &models.LatestExchangeRateResponse{
DataSource: nationalBankOfUkraineDataSource,
ReferenceUrl: nationalBankOfUkraineExchangeRateReferenceUrl,
UpdateTime: latestUpdateTime,
BaseCurrency: nationalBankOfUkraineBaseCurrency,
ExchangeRates: exchangeRates,
}
return latestExchangeRateResp
}
// ToLatestExchangeRate returns a data pair according to original data from National Bank of Ukraine
func (e *NaionalBankOfUkraineExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
if e.Rate <= 0 {
log.Warnf(c, "[national_bank_of_ukraine_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %f", e.Currency, e.Rate)
return nil
}
if e.Quantity <= 0 {
log.Warnf(c, "[national_bank_of_ukraine_datasource.ToLatestExchangeRate] quantity is invalid, currency is %s, quantity is %f", e.Currency, e.Quantity)
return nil
}
finalRate := e.Quantity / e.Rate
if math.IsInf(finalRate, 0) {
return nil
}
return &models.LatestExchangeRate{
Currency: e.Currency,
Rate: utils.Float64ToString(finalRate),
}
}
// BuildRequests returns the National Bank of Ukraine exchange rates http requests
func (e *NationalBankOfUkraineDataSource) BuildRequests() ([]*http.Request, error) {
req, err := http.NewRequest("GET", nationalBankOfUkraineExchangeRateUrl, nil)
if err != nil {
return nil, err
}
return []*http.Request{req}, nil
}
// Parse returns the common response entity according to the National Bank of Ukraine data source raw response
func (e *NationalBankOfUkraineDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
var nationalBankOfUkraineData NationalBankOfUkraineExchangeRates
err := json.Unmarshal(content, &nationalBankOfUkraineData)
if err != nil {
log.Errorf(c, "[national_bank_of_ukraine_datasource.Parse] failed to parse JSON data, content: %s, error: %s", string(content), err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
if len(nationalBankOfUkraineData) == 0 {
log.Errorf(c, "[national_bank_of_ukraine_datasource.Parse] exchange rate list is empty")
return nil, errs.ErrFailedToRequestRemoteApi
}
latestExchangeRateResponse := nationalBankOfUkraineData.ToLatestExchangeRateResponse(c)
if latestExchangeRateResponse == nil {
log.Errorf(c, "[national_bank_of_ukraine_datasource.Parse] failed to parse latest exchange rate data, content: %s", string(content))
return nil, errs.ErrFailedToRequestRemoteApi
}
return latestExchangeRateResponse, nil
}
@@ -0,0 +1,176 @@
package exchangerates
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
const nationalBankOfUkraineMinimumRequiredContent = "[\n" +
" {\n" +
" \"StartDate\": \"21.04.2025\",\n" +
" \"TimeSign\": \"0000\",\n" +
" \"CurrencyCode\": \"840\",\n" +
" \"CurrencyCodeL\": \"USD\",\n" +
" \"Units\": 1,\n" +
" \"Amount\": 41.3955\n" +
" },\n" +
" {\n" +
" \"StartDate\": \"21.04.2025\",\n" +
" \"TimeSign\": \"0000\",\n" +
" \"CurrencyCode\": \"392\",\n" +
" \"CurrencyCodeL\": \"JPY\",\n" +
" \"Units\": 10,\n" +
" \"Amount\": 2.907\n" +
" }\n" +
"]"
func TestNationalBankOfUkraineDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfUkraineMinimumRequiredContent))
assert.Equal(t, nil, err)
assert.Equal(t, "UAH", actualLatestExchangeRateResponse.BaseCurrency)
}
func TestNationalBankOfUkraineDataSource_StandardDataExtractUpdateTime(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfUkraineMinimumRequiredContent))
assert.Equal(t, nil, err)
assert.Equal(t, int64(1745193600), actualLatestExchangeRateResponse.UpdateTime)
}
func TestNationalBankOfUkraineDataSource_StandardDataExtractExchangeRates(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfUkraineMinimumRequiredContent))
assert.Equal(t, nil, err)
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
Currency: "USD",
Rate: "0.02415721515623679",
})
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
Currency: "JPY",
Rate: "3.4399724802201583",
})
}
func TestNationalBankOfUkraineDataSource_BlankContent(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
_, err := dataSource.Parse(context, []byte(""))
assert.NotEqual(t, nil, err)
}
func TestNationalBankOfUkraineDataSource_EmptyData(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
_, err := dataSource.Parse(context, []byte("[]"))
assert.NotEqual(t, nil, err)
}
func TestNationalBankOfUkraineDataSource_InvalidDate(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
_, err := dataSource.Parse(context, []byte("[\n"+
" {\n"+
" \"StartDate\": \"04.21.2025\",\n"+
" \"TimeSign\": \"0000\",\n"+
" \"CurrencyCode\": \"840\",\n"+
" \"CurrencyCodeL\": \"USD\",\n"+
" \"Units\": 1,\n"+
" \"Amount\": 41.3955\n"+
" }\n"+
"]"))
assert.NotEqual(t, nil, err)
}
func TestNationalBankOfUkraineDataSource_InvalidCurrency(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
" {\n"+
" \"StartDate\": \"21.04.2025\",\n"+
" \"TimeSign\": \"0000\",\n"+
" \"CurrencyCode\": \"840\",\n"+
" \"CurrencyCodeL\": \"XXX\",\n"+
" \"Units\": 1,\n"+
" \"Amount\": 41.3955\n"+
" }\n"+
"]"))
assert.Equal(t, nil, err)
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
}
func TestNationalBankOfUkraineDataSource_InvalidUnits(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
" {\n"+
" \"StartDate\": \"21.04.2025\",\n"+
" \"TimeSign\": \"0000\",\n"+
" \"CurrencyCode\": \"840\",\n"+
" \"CurrencyCodeL\": \"USD\",\n"+
" \"Units\": null,\n"+
" \"Amount\": 41.3955\n"+
" }\n"+
"]"))
assert.Equal(t, nil, err)
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
" {\n"+
" \"StartDate\": \"21.04.2025\",\n"+
" \"TimeSign\": \"0000\",\n"+
" \"CurrencyCode\": \"840\",\n"+
" \"CurrencyCodeL\": \"USD\",\n"+
" \"Units\": 0,\n"+
" \"Amount\": 41.3955\n"+
" }\n"+
"]"))
assert.Equal(t, nil, err)
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
}
func TestNationalBankOfUkraineDataSource_InvalidAmount(t *testing.T) {
dataSource := &NationalBankOfUkraineDataSource{}
context := core.NewNullContext()
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
" {\n"+
" \"StartDate\": \"21.04.2025\",\n"+
" \"TimeSign\": \"0000\",\n"+
" \"CurrencyCode\": \"840\",\n"+
" \"CurrencyCodeL\": \"USD\",\n"+
" \"Units\": 1,\n"+
" \"Amount\": null\n"+
" }\n"+
"]"))
assert.Equal(t, nil, err)
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
" {\n"+
" \"StartDate\": \"21.04.2025\",\n"+
" \"TimeSign\": \"0000\",\n"+
" \"CurrencyCode\": \"840\",\n"+
" \"CurrencyCodeL\": \"USD\",\n"+
" \"Units\": 1,\n"+
" \"Amount\": 0\n"+
" }\n"+
"]"))
assert.Equal(t, nil, err)
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
}
+3 -1
View File
@@ -116,6 +116,7 @@ const (
SwissNationalBankDataSource string = "swiss_national_bank" SwissNationalBankDataSource string = "swiss_national_bank"
CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan" CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
InternationalMonetaryFundDataSource string = "international_monetary_fund" InternationalMonetaryFundDataSource string = "international_monetary_fund"
NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
) )
const ( const (
@@ -910,7 +911,8 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio
dataSource == BankOfRussiaDataSource || dataSource == BankOfRussiaDataSource ||
dataSource == SwissNationalBankDataSource || dataSource == SwissNationalBankDataSource ||
dataSource == CentralBankOfUzbekistanDataSource || dataSource == CentralBankOfUzbekistanDataSource ||
dataSource == InternationalMonetaryFundDataSource { dataSource == InternationalMonetaryFundDataSource ||
dataSource == NationalBankOfUkraineDataSource {
config.ExchangeRatesDataSource = dataSource config.ExchangeRatesDataSource = dataSource
} else { } else {
return errs.ErrInvalidExchangeRatesDataSource return errs.ErrInvalidExchangeRatesDataSource