Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26397131d | |||
| 7659e8f0f7 | |||
| 90b608bdc6 | |||
| fffe2a1ccb | |||
| fd7706de6d | |||
| d0a5c93e49 | |||
| 263bf08f34 | |||
| e050f30efa | |||
| c2b1adf588 | |||
| 647cd3c33f | |||
| 8fdbb39ee4 | |||
| ee029294f1 | |||
| 563e328ce3 | |||
| 8f543d7a84 | |||
| 62e09190f3 | |||
| 50c774fd78 | |||
| 10e4bcc723 | |||
| 964ad6d046 | |||
| 56fb76017d | |||
| 5a9141e10c | |||
| db94282207 | |||
| 9f6446c30c | |||
| d570ce361d | |||
| 868fcf2c5a | |||
| dd35a85316 | |||
| 5003f8b3a2 | |||
| d044f938e3 | |||
| e549779164 | |||
| e2f2b325a6 | |||
| 9860c1db54 | |||
| 7d820f5b88 | |||
| 61d6e5643c | |||
| b444de591a | |||
| 21c86c9dfa | |||
| 8e70754533 | |||
| 4270d74338 | |||
| db506fa992 | |||
| c1b06eaa6f | |||
| 6bd1d09fa8 | |||
| 70da228dcc | |||
| 9888efe437 | |||
| 65756b62a5 | |||
| 59a0d593d4 | |||
| d519b80b61 | |||
| e92725f38b | |||
| ec0cb0bbb7 | |||
| a4b26374f4 | |||
| dcac6a4bb0 | |||
| dd6eecb0c2 | |||
| fec100a273 | |||
| 8f944b1b46 | |||
| 69498003d8 | |||
| e019f557ff | |||
| 4b5611ef6c | |||
| ca44b2cc2c | |||
| 10e0972d79 | |||
| 28908d81a3 | |||
| 0503a50754 | |||
| 65a92042d6 | |||
| f554fdefd3 | |||
| bdbd4d5302 | |||
| 3ee1683349 | |||
| 3a7ad429c2 | |||
| 89bd055f02 | |||
| 835b3b7b8b | |||
| 934f90cdff | |||
| 92cc683b8e | |||
| 80d548e8bd | |||
| 7ec1efb85d | |||
| f5945a788f | |||
| 2d0e2e0cca | |||
| bff6ca7e9d | |||
| 06b4960984 | |||
| 2fe393204b | |||
| 876950a84e | |||
| 6292ef9dfb | |||
| 798fb8f937 | |||
| f6dd4c03c3 | |||
| f87fbddef7 | |||
| aa2e10440d | |||
| 34b0b793ba | |||
| 1f159bf826 | |||
| b8253b6dcc | |||
| 79fd9070e4 | |||
| 7b96cd0447 | |||
| 01bc9becc0 | |||
| 9a009b73dc | |||
| fe35cbae49 | |||
| c3a880e5f5 | |||
| 1c906113ab | |||
| 6f3dcd958d | |||
| 7a9f4cd64f | |||
| 9a67af7c55 | |||
| 501de6ffef |
@@ -1,16 +0,0 @@
|
||||
module.exports = {
|
||||
'root': true,
|
||||
'env': {
|
||||
'node': true
|
||||
},
|
||||
'extends': [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-essential'
|
||||
],
|
||||
'rules': {
|
||||
'vue/no-use-v-if-with-v-for': 'off',
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true,
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -50,5 +50,7 @@ jobs:
|
||||
context: .
|
||||
platforms: ${{ vars.BUILD_SNAPSHOT_PLATFORMS }}
|
||||
push: true
|
||||
build-args: |
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -45,5 +45,7 @@ jobs:
|
||||
linux/arm/v7
|
||||
linux/arm/v6
|
||||
push: true
|
||||
build-args: |
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -26,3 +26,5 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
build-args: |
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
|
||||
+5
-3
@@ -1,7 +1,9 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.22.8-alpine3.20 AS be-builder
|
||||
FROM golang:1.23.4-alpine3.21 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG SKIP_TESTS
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
ENV SKIP_TESTS=$SKIP_TESTS
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
COPY . .
|
||||
RUN docker/backend-build-pre-setup.sh
|
||||
@@ -9,7 +11,7 @@ RUN apk add git gcc g++ libc-dev
|
||||
RUN ./build.sh backend
|
||||
|
||||
# Build frontend files
|
||||
FROM --platform=$BUILDPLATFORM node:20.18.0-alpine3.20 AS fe-builder
|
||||
FROM --platform=$BUILDPLATFORM node:20.18.1-alpine3.21 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
@@ -19,7 +21,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.20.3
|
||||
FROM alpine:3.21.0
|
||||
LABEL maintainer="MaysWind <i@mayswind.net>"
|
||||
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
||||
RUN apk --no-cache add tzdata
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
set "TYPE="
|
||||
set "NO_LINT=0"
|
||||
set "NO_TEST=0"
|
||||
set "SKIP_TESTS=%SKIP_TESTS%"
|
||||
set "RELEASE=%RELEASE_BUILD%"
|
||||
set "RELEASE_TYPE=unknown"
|
||||
set "VERSION="
|
||||
@@ -56,7 +57,7 @@ goto :pre_parse_args
|
||||
echo /r, --release Build release (The script will use environment variable "RELEASE_BUILD" to detect whether this is release building by default)
|
||||
echo /o, --output ^<filename^> Package file name (For "package" type only)
|
||||
echo --no-lint Do not execute lint check before building
|
||||
echo --no-test Do not execute unit testing before building
|
||||
echo --no-test Do not execute unit testing before building (You can use environment variable "SKIP_TESTS" to skip specified tests)
|
||||
echo /h, --help Show help
|
||||
goto :eof
|
||||
|
||||
@@ -139,7 +140,13 @@ goto :pre_parse_args
|
||||
if "%NO_TEST%"=="0" (
|
||||
echo Executing backend unit testing...
|
||||
call go clean -cache
|
||||
|
||||
if "%SKIP_TESTS%"=="" (
|
||||
call go test .\... -v
|
||||
) else (
|
||||
echo (Skip unit test "%SKIP_TESTS%")
|
||||
call go test .\... -v -skip "%SKIP_TESTS%"
|
||||
)
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
call :echo_red "Error: Failed to pass unit testing"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
TYPE=""
|
||||
NO_LINT="0"
|
||||
NO_TEST="0"
|
||||
SKIP_TESTS="${SKIP_TESTS}"
|
||||
RELEASE=${RELEASE_BUILD:-"0"}
|
||||
RELEASE_TYPE="unknown"
|
||||
VERSION=""
|
||||
@@ -43,7 +44,7 @@ Options:
|
||||
-o, --output <filename> Package file name (For "package" type only)
|
||||
-t, --tag Docker tag (For "docker" type only)
|
||||
--no-lint Do not execute lint check before building
|
||||
--no-test Do not execute unit testing before building
|
||||
--no-test Do not execute unit testing before building (You can use environment variable "SKIP_TESTS" to skip specified tests)
|
||||
-h, --help Show help
|
||||
EOF
|
||||
}
|
||||
@@ -137,7 +138,13 @@ build_backend() {
|
||||
if [ "$NO_TEST" = "0" ]; then
|
||||
echo "Executing backend unit testing..."
|
||||
go clean -cache
|
||||
|
||||
if [ -z "$SKIP_TESTS" ]; then
|
||||
go test ./... -v
|
||||
else
|
||||
echo "(Skip unit test \"$SKIP_TESTS\")"
|
||||
go test ./... -v -skip "$SKIP_TESTS"
|
||||
fi
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo_red "Error: Failed to pass unit testing"
|
||||
|
||||
@@ -114,6 +114,63 @@ var UserData = &cli.Command{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-set-restrict-features",
|
||||
Usage: "Set restrictions of user features",
|
||||
Action: bindAction(setUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-add-restrict-features",
|
||||
Usage: "Add restrictions of user features",
|
||||
Action: bindAction(addUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-remove-restrict-features",
|
||||
Usage: "Remove restrictions of user features",
|
||||
Action: bindAction(removeUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-resend-verify-email",
|
||||
Usage: "Resend user verify email",
|
||||
@@ -192,6 +249,19 @@ var UserData = &cli.Command{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-new",
|
||||
Usage: "Create new session for user",
|
||||
Action: bindAction(createNewUserToken),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-clear",
|
||||
Usage: "Clear user all sessions",
|
||||
@@ -423,6 +493,81 @@ func disableUser(c *core.CliContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
err = clis.UserData.SetUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.setUserFeatureRestriction] error occurs when setting user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.setUserFeatureRestriction] user \"%s\" has been set new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
|
||||
if featureRestriction < 1 {
|
||||
log.CliErrorf(c, "[user_data.addUserFeatureRestriction] nothing has been modified")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = clis.UserData.AddUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.addUserFeatureRestriction] error occurs when adding user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.addUserFeatureRestriction] user \"%s\" has been add new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
|
||||
if featureRestriction < 1 {
|
||||
log.CliErrorf(c, "[user_data.removeUserFeatureRestriction] nothing has been modified")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = clis.UserData.RemoveUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.removeUserFeatureRestriction] error occurs when removing user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.removeUserFeatureRestriction] user \"%s\" has been removed new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resendUserVerifyEmail(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
@@ -549,6 +694,27 @@ func listUserTokens(c *core.CliContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createNewUserToken(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
|
||||
return err
|
||||
}
|
||||
|
||||
printTokenInfo(token)
|
||||
fmt.Printf("[NewToken] %s\n", tokenString)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearUserTokens(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
@@ -735,6 +901,7 @@ func printUserInfo(user *models.User) {
|
||||
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
|
||||
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
|
||||
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
|
||||
fmt.Printf("[FeatureRestriction] %s (%d)\n", user.FeatureRestriction, user.FeatureRestriction)
|
||||
fmt.Printf("[Deleted] %t\n", user.Deleted)
|
||||
fmt.Printf("[EmailVerified] %t\n", user.EmailVerified)
|
||||
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(user.CreatedUnixTime), user.CreatedUnixTime)
|
||||
|
||||
+20
-15
@@ -103,6 +103,8 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.NoRoute(bindApi(api.Default.ApiNotFound))
|
||||
router.NoMethod(bindApi(api.Default.MethodNotAllowed))
|
||||
|
||||
serverSettingsCacheStore := persistence.NewInMemoryStore(time.Minute)
|
||||
|
||||
router.StaticFile("/", filepath.Join(config.StaticRootPath, "index.html"))
|
||||
router.Static("/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
@@ -114,12 +116,9 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.StaticFile("favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
|
||||
router.StaticFile("touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.GET("/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
mobileEntryRoute := router.Group("/mobile")
|
||||
mobileEntryRoute.Use(bindMiddleware(middlewares.ServerSettingsCookie(config)))
|
||||
{
|
||||
mobileEntryRoute.StaticFile("/", filepath.Join(config.StaticRootPath, "mobile.html"))
|
||||
}
|
||||
router.StaticFile("/mobile", filepath.Join(config.StaticRootPath, "mobile.html"))
|
||||
router.Static("/mobile/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/mobile/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
router.Static("/mobile/img", filepath.Join(config.StaticRootPath, "img"))
|
||||
@@ -129,16 +128,13 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.StaticFile("/mobile/touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("/mobile/manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("/mobile/sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/mobile/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/mobile/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
}
|
||||
|
||||
desktopEntryRoute := router.Group("/desktop")
|
||||
desktopEntryRoute.Use(bindMiddleware(middlewares.ServerSettingsCookie(config)))
|
||||
{
|
||||
desktopEntryRoute.StaticFile("/", filepath.Join(config.StaticRootPath, "desktop.html"))
|
||||
}
|
||||
router.StaticFile("/desktop", filepath.Join(config.StaticRootPath, "desktop.html"))
|
||||
router.Static("/desktop/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/desktop/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
router.Static("/desktop/img", filepath.Join(config.StaticRootPath, "img"))
|
||||
@@ -148,6 +144,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.StaticFile("/desktop/touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("/desktop/manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("/desktop/sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/desktop/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/desktop/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
@@ -171,11 +168,6 @@ func startWebServer(c *core.CliContext) error {
|
||||
|
||||
router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
|
||||
|
||||
if config.Mode == settings.MODE_DEVELOPMENT {
|
||||
devRoute := router.Group("/dev")
|
||||
devRoute.GET("/cookies", bindMiddleware(middlewares.ServerSettingsCookie(config)))
|
||||
}
|
||||
|
||||
proxyRoute := router.Group("/proxy")
|
||||
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
||||
{
|
||||
@@ -420,6 +412,19 @@ func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin
|
||||
}
|
||||
}
|
||||
|
||||
func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
|
||||
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, _, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/javascript", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, "text/javascript", "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
|
||||
+36
-2
@@ -217,6 +217,21 @@ avatar_provider = internal
|
||||
# For "internal" avatar provider only, maximum allowed user avatar file size (1 - 4294967295 bytes)
|
||||
max_user_avatar_size = 1048576
|
||||
|
||||
# The default feature restrictions after user registration (feature types separated by commas), leave blank for no restrictions
|
||||
# Supports the following feature types:
|
||||
# 1: Update Password
|
||||
# 2: Update Email
|
||||
# 3: Update Profile Basic Info
|
||||
# 4: Update Avatar
|
||||
# 5: Logout Other Session
|
||||
# 6: Enable Two-Factor Authentication
|
||||
# 7: Disable Enable Two-Factor Authentication
|
||||
# 8: Forget Password
|
||||
# 9: Import Transactions
|
||||
# 10: Export Transactions
|
||||
# 11: Clear All Data
|
||||
default_feature_restrictions =
|
||||
|
||||
[data]
|
||||
# Set to true to allow users to export their data
|
||||
enable_export = true
|
||||
@@ -227,6 +242,15 @@ enable_import = true
|
||||
# Maximum allowed import file size (1 - 4294967295 bytes)
|
||||
max_import_file_size = 10485760
|
||||
|
||||
[tip]
|
||||
# Set to true to display custom tips in login page
|
||||
enable_tips_in_login_page = false
|
||||
|
||||
# The custom tips displayed in login page, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
|
||||
# For example, login_page_tips_content_zh_hans means the notification content in Simplified Chinese
|
||||
login_page_tips_content =
|
||||
|
||||
[notification]
|
||||
# Set to true to display custom notification in home page every time users register
|
||||
enable_notification_after_register = false
|
||||
@@ -316,11 +340,21 @@ custom_map_tile_server_default_zoom_level = 14
|
||||
|
||||
[exchange_rates]
|
||||
# Exchange rates data source, supports the following types:
|
||||
# "euro_central_bank": https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html
|
||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||
# "reserve_bank_of_australia": https://www.rba.gov.au/statistics/frequency/exchange-rates.html
|
||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
||||
# "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/
|
||||
# "central_bank_of_myanmar": https://forex.cbm.gov.mm/index.php/fxrate
|
||||
# "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/
|
||||
# "national_bank_of_romania": https://www.bnr.ro/Exchange-rates-1224.aspx
|
||||
# "bank_of_russia": https://www.cbr.ru/eng/currency_base/daily/
|
||||
# "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/
|
||||
# "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx
|
||||
data_source = euro_central_bank
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import globals from 'globals';
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import js from '@eslint/js';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const gitignorePath = path.resolve(__dirname, '.gitignore');
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
});
|
||||
|
||||
export default [...compat.extends('eslint:recommended', 'plugin:vue/vue3-essential'),
|
||||
includeIgnoreFile(gitignorePath), {
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
files: [
|
||||
"**/*.{vue,js,jsx,cjs,mjs}"
|
||||
],
|
||||
rules: {
|
||||
'vue/no-use-v-if-with-v-for': 'off',
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true,
|
||||
}],
|
||||
},
|
||||
}];
|
||||
@@ -8,21 +8,22 @@ require (
|
||||
github.com/gin-contrib/cache v1.3.0
|
||||
github.com/gin-contrib/gzip v1.0.1
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.11.0
|
||||
github.com/go-playground/validator/v10 v10.22.0
|
||||
github.com/go-co-op/gocron/v2 v2.12.3
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/minio/minio-go/v7 v7.0.74
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/minio/minio-go/v7 v7.0.80
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/urfave/cli/v2 v2.27.3
|
||||
github.com/urfave/cli/v2 v2.27.4
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/text v0.17.0
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/text v0.19.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
xorm.io/builder v0.3.13
|
||||
@@ -56,7 +57,7 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
@@ -69,7 +70,7 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||
@@ -78,8 +79,7 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -49,16 +49,16 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/go-co-op/gocron/v2 v2.12.3 h1:3JkKjkFoAPp/i0YE+sonlF5gi+xnBChwYh75nX16MaE=
|
||||
github.com/go-co-op/gocron/v2 v2.12.3/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
@@ -80,8 +80,8 @@ github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
@@ -98,14 +98,14 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.74 h1:fTo/XlPBTSpo3BAMshlwKL5RspXRv9us5UeHEGYCFe0=
|
||||
github.com/minio/minio-go/v7 v7.0.74/go.mod h1:qydcVzV8Hqtj1VtEocfxbmVFa2siu6HGa+LDEPogjD8=
|
||||
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
|
||||
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -126,8 +126,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
@@ -152,8 +152,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/urfave/cli/v2 v2.27.3 h1:/POWahRmdh7uztQ3CYnaDddk0Rm90PyOgIxgW2rr41M=
|
||||
github.com/urfave/cli/v2 v2.27.3/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
|
||||
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
|
||||
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
@@ -161,23 +161,23 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBi
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
|
||||
Generated
+2389
-2195
File diff suppressed because it is too large
Load Diff
+26
-23
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -15,49 +15,52 @@
|
||||
"serve": "cross-env NODE_ENV=development vite",
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"serve:dist": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@vuepic/vue-datepicker": "^9.0.1",
|
||||
"axios": "^1.7.3",
|
||||
"@vuepic/vue-datepicker": "^10.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"cbor-js": "^0.1.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dom7": "^4.0.6",
|
||||
"echarts": "^5.5.1",
|
||||
"framework7": "^8.3.3",
|
||||
"framework7": "^8.3.4",
|
||||
"framework7-icons": "^5.0.5",
|
||||
"framework7-vue": "^8.3.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"framework7-vue": "^8.3.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"line-awesome": "^1.3.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"pinia": "^2.2.1",
|
||||
"moment-timezone": "^0.5.46",
|
||||
"pinia": "^2.2.5",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"skeleton-elements": "^4.0.1",
|
||||
"swiper": "^10.2.0",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"vue": "^3.4.37",
|
||||
"vue-echarts": "^6.7.3",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.4.3",
|
||||
"ua-parser-js": "^1.0.39",
|
||||
"vue": "^3.5.12",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-i18n": "^10.0.4",
|
||||
"vue-router": "^4.4.5",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.6.13"
|
||||
"vuetify": "^3.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@eslint/compat": "^1.2.2",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0",
|
||||
"git-rev-sync": "^3.0.2",
|
||||
"postcss-preset-env": "^9.5.16",
|
||||
"sass": "^1.77.6",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-pwa": "^0.20.0",
|
||||
"vite-plugin-vuetify": "^2.0.3"
|
||||
"globals": "^15.11.0",
|
||||
"postcss-preset-env": "^10.0.9",
|
||||
"sass": "^1.80.6",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-plugin-vuetify": "^2.0.4"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
|
||||
+79
-15
@@ -159,6 +159,11 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrAccountCategoryInvalid
|
||||
}
|
||||
|
||||
if accountCreateReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountCreateReq.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] cannot set statement date with category \"%d\"", accountCreateReq.Category)
|
||||
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
|
||||
}
|
||||
|
||||
if accountCreateReq.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||
if len(accountCreateReq.SubAccounts) > 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot have any sub-accounts")
|
||||
@@ -169,6 +174,11 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot set currency placeholder")
|
||||
return nil, errs.ErrAccountCurrencyInvalid
|
||||
}
|
||||
|
||||
if accountCreateReq.Balance != 0 && accountCreateReq.BalanceTime <= 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account balance time is not set")
|
||||
return nil, errs.ErrAccountBalanceTimeNotSet
|
||||
}
|
||||
} else if accountCreateReq.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
if len(accountCreateReq.SubAccounts) < 1 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account does not have any sub-accounts")
|
||||
@@ -189,19 +199,29 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
subAccount := accountCreateReq.SubAccounts[i]
|
||||
|
||||
if subAccount.Category != accountCreateReq.Category {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] category of sub-account not equals to parent")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] category of sub-account#%d not equals to parent", i)
|
||||
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
|
||||
}
|
||||
|
||||
if subAccount.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account type invalid")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d type invalid", i)
|
||||
return nil, errs.ErrSubAccountTypeInvalid
|
||||
}
|
||||
|
||||
if subAccount.Currency == validators.ParentAccountCurrencyPlaceholder {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account cannot set currency placeholder")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d cannot set currency placeholder", i)
|
||||
return nil, errs.ErrAccountCurrencyInvalid
|
||||
}
|
||||
|
||||
if subAccount.Balance != 0 && subAccount.BalanceTime <= 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d balance time is not set", i)
|
||||
return nil, errs.ErrAccountBalanceTimeNotSet
|
||||
}
|
||||
|
||||
if subAccount.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d cannot set statement date", i)
|
||||
return nil, errs.ErrCannotSetStatementDateForSubAccount
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account type invalid, type is %d", accountCreateReq.Type)
|
||||
@@ -216,8 +236,8 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, maxOrderId+1)
|
||||
childrenAccounts := a.createSubAccountModels(uid, &accountCreateReq)
|
||||
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, false, maxOrderId+1)
|
||||
childrenAccounts, childrenAccountBalanceTimes := a.createSubAccountModels(uid, &accountCreateReq)
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId)
|
||||
@@ -255,7 +275,7 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
}
|
||||
}
|
||||
|
||||
err = a.accounts.CreateAccounts(c, mainAccount, childrenAccounts, utcOffset)
|
||||
err = a.accounts.CreateAccounts(c, mainAccount, accountCreateReq.BalanceTime, childrenAccounts, childrenAccountBalanceTimes, utcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
|
||||
@@ -302,8 +322,9 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
|
||||
mainAccount, exists := accountMap[accountModifyReq.Id]
|
||||
|
||||
if _, exists := accountMap[accountModifyReq.Id]; !exists {
|
||||
if !exists {
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
@@ -311,10 +332,26 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrCannotAddOrDeleteSubAccountsWhenModify
|
||||
}
|
||||
|
||||
if accountModifyReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountModifyReq.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] cannot set statement date with category \"%d\"", accountModifyReq.Category)
|
||||
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
|
||||
}
|
||||
|
||||
if mainAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
||||
subAccount := accountModifyReq.SubAccounts[i]
|
||||
|
||||
if subAccount.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set statement date", i)
|
||||
return nil, errs.ErrCannotSetStatementDateForSubAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anythingUpdate := false
|
||||
var toUpdateAccounts []*models.Account
|
||||
|
||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, accountMap[accountModifyReq.Id])
|
||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
|
||||
|
||||
if toUpdateAccount != nil {
|
||||
anythingUpdate = true
|
||||
@@ -328,7 +365,7 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id])
|
||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
|
||||
|
||||
if toUpdateSubAccount != nil {
|
||||
anythingUpdate = true
|
||||
@@ -468,7 +505,13 @@ func (a *AccountsApi) AccountDeleteHandler(c *core.WebContext) (any, *errs.Error
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, order int32) *models.Account {
|
||||
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, isSubAccount bool, order int32) *models.Account {
|
||||
accountExtend := &models.AccountExtend{}
|
||||
|
||||
if !isSubAccount && accountCreateReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
accountExtend.CreditCardStatementDate = &accountCreateReq.CreditCardStatementDate
|
||||
}
|
||||
|
||||
return &models.Account{
|
||||
Uid: uid,
|
||||
Name: accountCreateReq.Name,
|
||||
@@ -480,24 +523,33 @@ func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.
|
||||
Currency: accountCreateReq.Currency,
|
||||
Balance: accountCreateReq.Balance,
|
||||
Comment: accountCreateReq.Comment,
|
||||
Extend: accountExtend,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) []*models.Account {
|
||||
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) ([]*models.Account, []int64) {
|
||||
if len(accountCreateReq.SubAccounts) <= 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
childrenAccounts := make([]*models.Account, len(accountCreateReq.SubAccounts))
|
||||
childrenAccountBalanceTimes := make([]int64, len(accountCreateReq.SubAccounts))
|
||||
|
||||
for i := int32(0); i < int32(len(accountCreateReq.SubAccounts)); i++ {
|
||||
childrenAccounts[i] = a.createNewAccountModel(uid, accountCreateReq.SubAccounts[i], i+1)
|
||||
childrenAccounts[i] = a.createNewAccountModel(uid, accountCreateReq.SubAccounts[i], true, i+1)
|
||||
childrenAccountBalanceTimes[i] = accountCreateReq.SubAccounts[i].BalanceTime
|
||||
}
|
||||
|
||||
return childrenAccounts
|
||||
return childrenAccounts, childrenAccountBalanceTimes
|
||||
}
|
||||
|
||||
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account) *models.Account {
|
||||
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) *models.Account {
|
||||
newAccountExtend := &models.AccountExtend{}
|
||||
|
||||
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
|
||||
}
|
||||
|
||||
newAccount := &models.Account{
|
||||
AccountId: oldAccount.AccountId,
|
||||
Uid: uid,
|
||||
@@ -506,6 +558,7 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
Icon: accountModifyReq.Icon,
|
||||
Color: accountModifyReq.Color,
|
||||
Comment: accountModifyReq.Comment,
|
||||
Extend: newAccountExtend,
|
||||
Hidden: accountModifyReq.Hidden,
|
||||
}
|
||||
|
||||
@@ -518,5 +571,16 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
return newAccount
|
||||
}
|
||||
|
||||
if (newAccount.Extend != nil && oldAccount.Extend == nil) ||
|
||||
(newAccount.Extend == nil && oldAccount.Extend != nil) {
|
||||
return newAccount
|
||||
}
|
||||
|
||||
oldAccountExtend := oldAccount.Extend
|
||||
|
||||
if newAccountExtend.CreditCardStatementDate != oldAccountExtend.CreditCardStatementDate {
|
||||
return newAccount
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,6 +147,10 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.templates.DeleteAllTemplates(c, uid)
|
||||
|
||||
if err != nil {
|
||||
@@ -204,6 +208,10 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
||||
return nil, "", errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION) {
|
||||
return nil, "", errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -55,11 +55,17 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *
|
||||
Timeout: time.Duration(a.CurrentConfig().ExchangeRatesRequestTimeout) * time.Millisecond,
|
||||
}
|
||||
|
||||
urls := dataSource.GetRequestUrls()
|
||||
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(urls))
|
||||
requests, err := dataSource.BuildRequests()
|
||||
|
||||
for i := 0; i < len(urls); i++ {
|
||||
req, _ := http.NewRequest("GET", urls[i], nil)
|
||||
if err != nil {
|
||||
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(requests))
|
||||
|
||||
for i := 0; i < len(requests); i++ {
|
||||
req := requests[i]
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s ", settings.Version))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -76,6 +82,9 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
log.Debugf(c, "[exchange_rates.LatestExchangeRateHandler] response#%d is %s", i, body)
|
||||
|
||||
exchangeRateResp, err := dataSource.Parse(c, body)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_ReserveBankOfAustraliaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.ReserveBankOfAustraliaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "AUD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"CAD", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
|
||||
"MYR", "NZD", "PHP", "SGD", "THB", "TWD", "USD", "VND"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfCanadaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "CAD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BRL", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR",
|
||||
"JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PEN", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD",
|
||||
"USD", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CzechNationalBankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CzechNationalBankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "CZK", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN",
|
||||
"BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD",
|
||||
"CAD", "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "DJF", "DKK", "DOP", "DZD",
|
||||
"EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD",
|
||||
"HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD", "JOD", "JPY",
|
||||
"KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD",
|
||||
"MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN",
|
||||
"NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG",
|
||||
"QAR", "RON", "RSD", "RUB", "RWF",
|
||||
"SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL",
|
||||
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "UYU", "UZS",
|
||||
"VND", "VUV", "WST", "XAF", "XCD", "XOF", "XPF", "YER", "ZAR", "ZMW"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_DanmarksNationalbankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.DanmarksNationalbankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "DKK", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "EUR", "GBP", "HKD", "HUF",
|
||||
"IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PLN", "RON", "SEK", "SGD",
|
||||
"THB", "TRY", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_EuroCentralBankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.EuroCentralBankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "EUR", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "GBP", "HKD", "HUF",
|
||||
"IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PLN", "RON", "SEK", "SGD",
|
||||
"THB", "TRY", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfGeorgiaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfGeorgiaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "GEL", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BGN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK",
|
||||
"DKK", "EGP", "EUR", "GBP", "HKD", "HUF", "ILS", "INR", "IRR", "ISK", "JPY", "KGS", "KRW", "KWD", "KZT",
|
||||
"MDL", "NOK", "NZD", "PLN", "QAR", "RON", "RSD", "RUB", "SEK", "SGD", "TJS", "TMT", "TRY",
|
||||
"UAH", "USD", "UZS", "ZAR"}
|
||||
|
||||
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)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "ILS", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "CAD", "CHF", "DKK", "EGP", "EUR", "GBP",
|
||||
"JOD", "JPY", "LBP", "NOK", "SEK", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfMyanmarDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfMyanmarDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "MMK", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BDT", "BND", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK",
|
||||
"EGP", "EUR", "GBP", "HKD", "IDR", "ILS", "INR", "JPY", "KES", "KHR", "KRW", "KWD", "LAK", "LKR",
|
||||
"MYR", "NOK", "NPR", "NZD", "PHP", "PKR", "RSD", "RUB", "SAR", "SEK", "SGD", "THB", "USD", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NorgesBankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NorgesBankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "NOK", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BDT", "BGN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK", "DKK",
|
||||
"EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MMK", "MXN", "MYR", "NZD",
|
||||
"PHP", "PKR", "PLN", "RON", "RUB", "SEK", "SGD", "THB", "TRY", "TWD", "USD", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfPolandDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfPolandDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "PLN", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN",
|
||||
"BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BND", "BOB", "BRL", "BSD", "BWP", "BYN", "BZD",
|
||||
"CAD", "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "CZK",
|
||||
"DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD",
|
||||
"GBP", "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD",
|
||||
"HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK",
|
||||
"JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KRW", "KWD", "KZT",
|
||||
"LAK", "LBP", "LKR", "LRD", "LSL", "LYD",
|
||||
"MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN",
|
||||
"NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PYG",
|
||||
"QAR", "RON", "RSD", "RUB", "RWF",
|
||||
"SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL",
|
||||
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "UYU", "UZS",
|
||||
"VES", "VND", "VUV", "WST", "XAF", "XCD", "XOF", "XPF", "YER", "ZAR", "ZMW", "ZWG"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfRomaniaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfRomaniaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "RON", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "EGP",
|
||||
"EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MDL", "MXN", "MYR",
|
||||
"NOK", "NZD", "PHP", "PLN", "RSD", "RUB", "SEK", "SGD", "THB", "TRY", "UAH", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfRussiaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfRussiaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "RUB", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BGN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK",
|
||||
"DKK", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "INR", "JPY", "KGS", "KRW", "KZT", "MDL",
|
||||
"NOK", "NZD", "PLN", "QAR", "RON", "RSD", "SEK", "SGD", "THB", "TJS", "TMT", "TRY",
|
||||
"UAH", "USD", "UZS", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_SwissNationalBankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.SwissNationalBankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "CHF", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"EUR", "GBP", "JPY", "USD"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfUzbekistanDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfUzbekistanDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "UZS", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AFN", "AMD", "ARS", "AUD", "AZN",
|
||||
"BDT", "BGN", "BHD", "BND", "BRL", "BYN", "CAD", "CHF", "CNY", "CUP", "CZK",
|
||||
"DKK", "DZD", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK",
|
||||
"JOD", "JPY", "KGS", "KHR", "KRW", "KWD", "KZT", "LAK", "LBP", "LYD",
|
||||
"MAD", "MDL", "MMK", "MNT", "MXN", "MYR", "NOK", "NZD", "OMR", "PHP", "PKR", "PLN",
|
||||
"QAR", "RON", "RSD", "RUB", "SAR", "SDG", "SEK", "SGD", "SYP",
|
||||
"THB", "TJS", "TMT", "TND", "TRY", "UAH", "USD", "UYU", "VES", "VND", "YER", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_InternationalMonetaryFundDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.InternationalMonetaryFundDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "USD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AUD", "BND", "BRL", "BWP", "CAD", "CHF", "CLP", "CNY", "CZK",
|
||||
"DKK", "DZD", "EUR", "GBP", "ILS", "INR", "JPY", "KRW", "KWD", "MUR", "MXN", "MYR", "NOK", "NZD",
|
||||
"OMR", "PEN", "PHP", "PLN", "QAR", "RUB", "SAR", "SEK", "SGD", "THB", "TTD", "UYU", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *models.LatestExchangeRateResponse {
|
||||
config := &settings.Config{
|
||||
ExchangeRatesDataSource: dataSourceType,
|
||||
ExchangeRatesRequestTimeout: 10000,
|
||||
ExchangeRatesProxy: "system",
|
||||
ExchangeRatesSkipTLSVerify: true,
|
||||
}
|
||||
|
||||
settingsContainer := &settings.ConfigContainer{
|
||||
Current: config,
|
||||
}
|
||||
|
||||
err := exchangerates.InitializeExchangeRatesDataSource(config)
|
||||
assert.Nil(t, err)
|
||||
|
||||
exchangeRatesApi := &ExchangeRatesApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settingsContainer,
|
||||
},
|
||||
}
|
||||
|
||||
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
response, err := exchangeRatesApi.LatestExchangeRateHandler(&core.WebContext{
|
||||
Context: ginContext,
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
|
||||
exchangeRateResponse := response.(*models.LatestExchangeRateResponse)
|
||||
assert.NotNil(t, exchangeRateResponse)
|
||||
|
||||
return exchangeRateResponse
|
||||
}
|
||||
|
||||
func checkExchangeRatesHaveSpecifiedCurrencies(t *testing.T, baseCurrency string, currencyCodes []string, exchangeRates []*models.LatestExchangeRate) {
|
||||
assert.Equal(t, len(currencyCodes)+1, len(exchangeRates))
|
||||
|
||||
currencyCodesInExchangeRates := make(map[string]*models.LatestExchangeRate, len(exchangeRates))
|
||||
|
||||
for i := 0; i < len(exchangeRates); i++ {
|
||||
exchangeRate := exchangeRates[i]
|
||||
currencyCodesInExchangeRates[exchangeRate.Currency] = exchangeRate
|
||||
}
|
||||
|
||||
allCurrencyCodes := append(currencyCodes, baseCurrency)
|
||||
|
||||
for i := 0; i < len(allCurrencyCodes); i++ {
|
||||
exchangeRate, has := currencyCodesInExchangeRates[allCurrencyCodes[i]]
|
||||
assert.True(t, has, allCurrencyCodes[i])
|
||||
|
||||
if has {
|
||||
rate, err := utils.StringToFloat64(exchangeRate.Rate)
|
||||
assert.Nil(t, err)
|
||||
assert.Greater(t, rate, float64(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if a.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
@@ -109,6 +113,10 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any,
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if a.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const ezbookkeepingServerSettingsGlobalVariableName = "EZBOOKKEEPING_SERVER_SETTINGS"
|
||||
const ezbookkeepingServerSettingsGlobalVariableFullName = "window." + ezbookkeepingServerSettingsGlobalVariableName
|
||||
const ezbookkeepingServerSettingsJavascriptFileHeader = ezbookkeepingServerSettingsGlobalVariableFullName +
|
||||
"=" + ezbookkeepingServerSettingsGlobalVariableFullName + "||{};\n"
|
||||
|
||||
// ServerSettingsApi represents server settings api
|
||||
type ServerSettingsApi struct {
|
||||
ApiUsingConfig
|
||||
}
|
||||
|
||||
// Initialize a server settings api singleton instance
|
||||
var (
|
||||
ServerSettings = &ServerSettingsApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// ServerSettingsJavascriptHandler returns the javascript contains server settings
|
||||
func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
|
||||
config := a.CurrentConfig()
|
||||
builder := &strings.Builder{}
|
||||
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
|
||||
|
||||
a.appendBooleanSetting(builder, "r", config.EnableUserRegister)
|
||||
a.appendBooleanSetting(builder, "f", config.EnableUserForgetPassword)
|
||||
a.appendBooleanSetting(builder, "v", config.EnableUserVerifyEmail)
|
||||
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
|
||||
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
|
||||
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
|
||||
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
|
||||
|
||||
if config.LoginPageTips.Enabled {
|
||||
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
|
||||
}
|
||||
|
||||
a.appendStringSetting(builder, "m", config.MapProvider)
|
||||
|
||||
if config.EnableMapDataFetchProxy &&
|
||||
(config.MapProvider == settings.OpenStreetMapProvider ||
|
||||
config.MapProvider == settings.OpenStreetMapHumanitarianStyleProvider ||
|
||||
config.MapProvider == settings.OpenTopoMapProvider ||
|
||||
config.MapProvider == settings.OPNVKarteMapProvider ||
|
||||
config.MapProvider == settings.CyclOSMMapProvider ||
|
||||
config.MapProvider == settings.CartoDBMapProvider ||
|
||||
config.MapProvider == settings.TomTomMapProvider ||
|
||||
config.MapProvider == settings.TianDiTuProvider ||
|
||||
config.MapProvider == settings.CustomProvider) {
|
||||
a.appendBooleanSetting(builder, "mp", config.EnableMapDataFetchProxy)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.CustomProvider {
|
||||
a.appendStringSetting(builder, "cmzl", fmt.Sprintf("%d-%d-%d", config.CustomMapTileServerMinZoomLevel, config.CustomMapTileServerMaxZoomLevel, config.CustomMapTileServerDefaultZoomLevel))
|
||||
|
||||
if !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "cmsu", config.CustomMapTileServerTileLayerUrl)
|
||||
|
||||
if config.CustomMapTileServerAnnotationLayerUrl != "" {
|
||||
a.appendStringSetting(builder, "cmau", config.CustomMapTileServerAnnotationLayerUrl)
|
||||
}
|
||||
} else {
|
||||
if config.CustomMapTileServerAnnotationLayerUrl != "" {
|
||||
a.appendBooleanSetting(builder, "cmap", config.EnableMapDataFetchProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TomTomMapProvider && config.TomTomMapAPIKey != "" && !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "tmak", config.TomTomMapAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TianDiTuProvider && config.TianDiTuAPIKey != "" && !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "tdak", config.TianDiTuAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.GoogleMapProvider && config.GoogleMapAPIKey != "" {
|
||||
a.appendStringSetting(builder, "gmak", config.GoogleMapAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.BaiduMapProvider && config.BaiduMapAK != "" {
|
||||
a.appendStringSetting(builder, "bmak", config.BaiduMapAK)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapApplicationKey != "" {
|
||||
a.appendStringSetting(builder, "amak", config.AmapApplicationKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod != "" {
|
||||
a.appendStringSetting(builder, "amsv", config.AmapSecurityVerificationMethod)
|
||||
|
||||
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationExternalProxyMethod {
|
||||
a.appendStringSetting(builder, "amep", config.AmapApiExternalProxyUrl)
|
||||
}
|
||||
|
||||
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationPlainTextMethod {
|
||||
a.appendStringSetting(builder, "amas", config.AmapApplicationSecret)
|
||||
}
|
||||
}
|
||||
|
||||
if config.ExchangeRatesRequestTimeoutExceedDefaultValue {
|
||||
a.appendIntegerSetting(builder, "errt", int(config.ExchangeRatesRequestTimeout))
|
||||
}
|
||||
|
||||
return []byte(builder.String()), "", nil
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key string, value string) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
|
||||
a.appendEncodedString(builder, value)
|
||||
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.TipConfig) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]={\n")
|
||||
|
||||
builder.WriteString("'default'")
|
||||
builder.WriteRune(':')
|
||||
a.appendEncodedString(builder, value.DefaultContent)
|
||||
|
||||
for languageTag, content := range value.MultiLanguageContent {
|
||||
builder.WriteString(",\n")
|
||||
a.appendEncodedString(builder, languageTag)
|
||||
builder.WriteRune(':')
|
||||
a.appendEncodedString(builder, content)
|
||||
}
|
||||
|
||||
builder.WriteString("\n};\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendBooleanSetting(builder *strings.Builder, key string, value bool) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
|
||||
if value {
|
||||
builder.WriteRune('1')
|
||||
} else {
|
||||
builder.WriteRune('0')
|
||||
}
|
||||
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendIntegerSetting(builder *strings.Builder, key string, value int) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
builder.WriteString(utils.IntToString(value))
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendEncodedString(builder *strings.Builder, content string) {
|
||||
builder.WriteRune('\'')
|
||||
runes := []rune(content)
|
||||
|
||||
for i := 0; i < len(runes); i++ {
|
||||
switch runes[i] {
|
||||
case '\\':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('\\')
|
||||
case '\'':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('\'')
|
||||
case '\n':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('n')
|
||||
case '\r':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('r')
|
||||
case '\t':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('t')
|
||||
case '\f':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('f')
|
||||
case '\b':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('b')
|
||||
default:
|
||||
builder.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteRune('\'')
|
||||
}
|
||||
@@ -135,6 +135,22 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
return nil, errs.ErrInvalidTokenId
|
||||
}
|
||||
|
||||
if utils.Int64ToString(tokenRecord.UserTokenId) != c.GetTokenClaims().UserTokenId || tokenRecord.CreatedUnixTime != c.GetTokenClaims().IssuedAt {
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[token.TokenRevokeHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteToken(c, tokenRecord)
|
||||
|
||||
if err != nil {
|
||||
@@ -170,6 +186,24 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error)
|
||||
|
||||
tokens = append(tokens[:currentTokenIndex], tokens[currentTokenIndex+1:]...)
|
||||
|
||||
if len(tokens) < 1 {
|
||||
return nil, errs.ErrTokenRecordNotFound
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteTokens(c, uid, tokens)
|
||||
|
||||
if err != nil {
|
||||
|
||||
+38
-6
@@ -89,7 +89,7 @@ func (a *TransactionsApi) TransactionCountHandler(c *core.WebContext) (any, *err
|
||||
}
|
||||
}
|
||||
|
||||
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionCountReq.AmountFilter, transactionCountReq.Keyword)
|
||||
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionCountReq.TagFilterType, transactionCountReq.AmountFilter, transactionCountReq.Keyword)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionCountHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -160,7 +160,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
|
||||
var totalCount int64
|
||||
|
||||
if transactionListReq.WithCount {
|
||||
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -168,7 +168,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
|
||||
}
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByMaxTime(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.Page, transactionListReq.Count, true, true)
|
||||
transactions, err := a.transactions.GetTransactionsByMaxTime(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.Page, transactionListReq.Count, true, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transactions earlier than \"%d\" for user \"uid:%d\", because %s", transactionListReq.MaxTime, uid, err.Error())
|
||||
@@ -260,7 +260,7 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
|
||||
}
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to get transactions in month \"%d-%d\" for user \"uid:%d\", because %s", transactionListReq.Year, transactionListReq.Month, uid, err.Error())
|
||||
@@ -299,8 +299,20 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any,
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
var allTagIds []int64
|
||||
noTags := statisticReq.TagIds == "none"
|
||||
|
||||
if !noTags {
|
||||
allTagIds, err = a.getTagIds(statisticReq.TagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionStatisticsHandler] get transaction tag ids error, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, utcOffset, statisticReq.UseTransactionTimezone)
|
||||
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, allTagIds, noTags, statisticReq.TagFilterType, utcOffset, statisticReq.UseTransactionTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionStatisticsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -350,8 +362,20 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
var allTagIds []int64
|
||||
noTags := statisticTrendsReq.TagIds == "none"
|
||||
|
||||
if !noTags {
|
||||
allTagIds, err = a.getTagIds(statisticTrendsReq.TagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionStatisticsTrendsHandler] get transaction tag ids error, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, utcOffset, statisticTrendsReq.UseTransactionTimezone)
|
||||
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, allTagIds, noTags, statisticTrendsReq.TagFilterType, utcOffset, statisticTrendsReq.UseTransactionTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionStatisticsTrendsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -1077,6 +1101,10 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(c, user.Uid)
|
||||
|
||||
if err != nil {
|
||||
@@ -1201,6 +1229,10 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
newTransactions := make([]*models.Transaction, len(transactionImportReq.Transactions))
|
||||
|
||||
for i := 0; i < len(transactionImportReq.Transactions); i++ {
|
||||
|
||||
@@ -81,6 +81,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.WebCo
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user)
|
||||
|
||||
if err != nil {
|
||||
@@ -141,6 +145,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.WebCo
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
twoFactorSetting := &models.TwoFactor{
|
||||
Uid: uid,
|
||||
Secret: confirmReq.Secret,
|
||||
@@ -229,6 +237,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.WebContext)
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(disableReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
DefaultCurrency: userRegisterReq.DefaultCurrency,
|
||||
FirstDayOfWeek: userRegisterReq.FirstDayOfWeek,
|
||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
err = a.users.CreateUser(c, user)
|
||||
@@ -251,6 +252,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
userUpdateReq.Email = strings.TrimSpace(userUpdateReq.Email)
|
||||
userUpdateReq.Nickname = strings.TrimSpace(userUpdateReq.Nickname)
|
||||
|
||||
modifyProfileBasicInfo := false
|
||||
anythingUpdate := false
|
||||
userNew := &models.User{
|
||||
Uid: user.Uid,
|
||||
@@ -258,12 +260,20 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
}
|
||||
|
||||
if userUpdateReq.Email != "" && userUpdateReq.Email != user.Email {
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
user.Email = userUpdateReq.Email
|
||||
userNew.Email = userUpdateReq.Email
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.Password != "" {
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
@@ -277,6 +287,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.Nickname != "" && userUpdateReq.Nickname != user.Nickname {
|
||||
user.Nickname = userUpdateReq.Nickname
|
||||
userNew.Nickname = userUpdateReq.Nickname
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
@@ -299,12 +310,14 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
|
||||
user.DefaultAccountId = userUpdateReq.DefaultAccountId
|
||||
userNew.DefaultAccountId = userUpdateReq.DefaultAccountId
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
|
||||
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.TransactionEditScope = models.TRANSACTION_EDIT_SCOPE_INVALID
|
||||
@@ -316,18 +329,21 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
user.Language = userUpdateReq.Language
|
||||
userNew.Language = userUpdateReq.Language
|
||||
modifyUserLanguage = true
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.DefaultCurrency != "" && userUpdateReq.DefaultCurrency != user.DefaultCurrency {
|
||||
user.DefaultCurrency = userUpdateReq.DefaultCurrency
|
||||
userNew.DefaultCurrency = userUpdateReq.DefaultCurrency
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.FirstDayOfWeek != nil && *userUpdateReq.FirstDayOfWeek != user.FirstDayOfWeek {
|
||||
user.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
|
||||
userNew.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
|
||||
@@ -336,6 +352,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
|
||||
user.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.LongDateFormat = core.LONG_DATE_FORMAT_INVALID
|
||||
@@ -344,6 +361,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.ShortDateFormat != nil && *userUpdateReq.ShortDateFormat != user.ShortDateFormat {
|
||||
user.ShortDateFormat = *userUpdateReq.ShortDateFormat
|
||||
userNew.ShortDateFormat = *userUpdateReq.ShortDateFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ShortDateFormat = core.SHORT_DATE_FORMAT_INVALID
|
||||
@@ -352,6 +370,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.LongTimeFormat != nil && *userUpdateReq.LongTimeFormat != user.LongTimeFormat {
|
||||
user.LongTimeFormat = *userUpdateReq.LongTimeFormat
|
||||
userNew.LongTimeFormat = *userUpdateReq.LongTimeFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.LongTimeFormat = core.LONG_TIME_FORMAT_INVALID
|
||||
@@ -360,6 +379,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.ShortTimeFormat != nil && *userUpdateReq.ShortTimeFormat != user.ShortTimeFormat {
|
||||
user.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
|
||||
userNew.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
|
||||
@@ -368,6 +388,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
|
||||
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DecimalSeparator = core.DECIMAL_SEPARATOR_INVALID
|
||||
@@ -376,6 +397,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.DigitGroupingSymbol != nil && *userUpdateReq.DigitGroupingSymbol != user.DigitGroupingSymbol {
|
||||
user.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
|
||||
userNew.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DigitGroupingSymbol = core.DIGIT_GROUPING_SYMBOL_INVALID
|
||||
@@ -384,6 +406,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.DigitGrouping != nil && *userUpdateReq.DigitGrouping != user.DigitGrouping {
|
||||
user.DigitGrouping = *userUpdateReq.DigitGrouping
|
||||
userNew.DigitGrouping = *userUpdateReq.DigitGrouping
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
|
||||
@@ -392,6 +415,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
||||
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
||||
@@ -400,6 +424,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
|
||||
user.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
|
||||
userNew.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ExpenseAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
|
||||
@@ -408,11 +433,16 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.IncomeAmountColor != nil && *userUpdateReq.IncomeAmountColor != user.IncomeAmountColor {
|
||||
user.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
|
||||
userNew.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.IncomeAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
|
||||
}
|
||||
|
||||
if modifyProfileBasicInfo && user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if modifyUserLanguage || userNew.DecimalSeparator != core.DECIMAL_SEPARATOR_INVALID || userNew.DigitGroupingSymbol != core.DIGIT_GROUPING_SYMBOL_INVALID {
|
||||
decimalSeparator := userNew.DecimalSeparator
|
||||
digitGroupingSymbol := userNew.DigitGroupingSymbol
|
||||
@@ -525,6 +555,10 @@ func (a *UsersApi) UserUpdateAvatarHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
@@ -588,6 +622,10 @@ func (a *UsersApi) UserRemoveAvatarHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if user.CustomAvatarType == "" {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ func (l *UserDataCli) AddNewUser(c *core.CliContext, username string, email stri
|
||||
DefaultCurrency: defaultCurrency,
|
||||
FirstDayOfWeek: core.WEEKDAY_SUNDAY,
|
||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||
FeatureRestriction: l.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
err := l.users.CreateUser(c, user)
|
||||
@@ -237,6 +238,57 @@ func (l *UserDataCli) DisableUser(c *core.CliContext, username string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserFeatureRestrictions sets user feature restrictions according to the specified user name
|
||||
func (l *UserDataCli) SetUserFeatureRestrictions(c *core.CliContext, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.SetUserFeatureRestrictions] user name is empty")
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
err := l.users.UpdateUserFeatureRestriction(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.SetUserFeatureRestrictions] failed to set user feature restrictions by user name \"%s\", because %s", username, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddUserFeatureRestrictions adds user feature restrictions according to the specified user name
|
||||
func (l *UserDataCli) AddUserFeatureRestrictions(c *core.CliContext, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.AddUserFeatureRestrictions] user name is empty")
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
err := l.users.AddUserFeatureRestriction(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.AddUserFeatureRestrictions] failed to add user feature restrictions by user name \"%s\", because %s", username, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserFeatureRestrictions removes user feature restrictions according to the specified user name
|
||||
func (l *UserDataCli) RemoveUserFeatureRestrictions(c *core.CliContext, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.RemoveUserFeatureRestrictions] user name is empty")
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
err := l.users.RemoveUserFeatureRestriction(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.RemoveUserFeatureRestrictions] failed to remove user feature restrictions by user name \"%s\", because %s", username, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResendVerifyEmail resends an email with account activation link
|
||||
func (l *UserDataCli) ResendVerifyEmail(c *core.CliContext, username string) error {
|
||||
if !l.CurrentConfig().EnableUserVerifyEmail {
|
||||
@@ -352,6 +404,30 @@ func (l *UserDataCli) ListUserTokens(c *core.CliContext, username string) ([]*mo
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// CreateNewUserToken returns a new token for the specified user
|
||||
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string) (*models.TokenRecord, string, error) {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] user name is empty")
|
||||
return nil, "", errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
user, err := l.GetUserByUsername(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] error occurs when getting user by user name")
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
token, tokenRecord, err := l.tokens.CreateTokenViaCli(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] failed to create token for user \"%s\", because %s", username, err.Error())
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return tokenRecord, token, nil
|
||||
}
|
||||
|
||||
// ClearUserTokens clears all tokens of the specified user
|
||||
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
|
||||
if username == "" {
|
||||
|
||||
@@ -109,7 +109,7 @@ func (t *weChatPayTransactionDataRowParser) Parse(ctx core.Context, user *models
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.WeChatWallet
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
|
||||
} else {
|
||||
log.Warnf(ctx, "[wechat_pay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because unkown transfer transaction category \"%s\"", rowId, data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY])
|
||||
log.Warnf(ctx, "[wechat_pay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because unknown transfer transaction category \"%s\"", rowId, data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY])
|
||||
return nil, false, nil
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserFeatureRestrictions represents all the restrictions of user features
|
||||
type UserFeatureRestrictions uint64
|
||||
|
||||
// Add returns a new feature restrictions with the specified feature
|
||||
func (r UserFeatureRestrictions) Add(featureRestrictionType UserFeatureRestrictionType) UserFeatureRestrictions {
|
||||
typeValue := uint64(1 << (featureRestrictionType - 1))
|
||||
return UserFeatureRestrictions(uint64(r) | typeValue)
|
||||
}
|
||||
|
||||
// Remove returns a new feature restrictions without the specified feature
|
||||
func (r UserFeatureRestrictions) Remove(featureRestrictionType UserFeatureRestrictionType) UserFeatureRestrictions {
|
||||
typeValue := uint64(1 << (featureRestrictionType - 1))
|
||||
return UserFeatureRestrictions(uint64(r) & (^typeValue))
|
||||
}
|
||||
|
||||
// Contains returns whether contains the specified feature
|
||||
func (r UserFeatureRestrictions) Contains(featureRestrictionType UserFeatureRestrictionType) bool {
|
||||
typeValue := uint64(1 << (featureRestrictionType - 1))
|
||||
return uint64(r)&typeValue == typeValue
|
||||
}
|
||||
|
||||
// String returns a textual representation of all the restrictions of user features
|
||||
func (r UserFeatureRestrictions) String() string {
|
||||
builder := strings.Builder{}
|
||||
|
||||
for restrictionType := USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD; restrictionType <= USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA; restrictionType++ {
|
||||
if !r.Contains(restrictionType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteRune(',')
|
||||
}
|
||||
|
||||
builder.WriteString(restrictionType.String())
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// ParseUserFeatureRestrictions returns restrictions of user features according to the textual restrictions of user features separated by commas
|
||||
func ParseUserFeatureRestrictions(featureRestrictions string) UserFeatureRestrictions {
|
||||
if len(featureRestrictions) < 1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
restrictions := uint64(0)
|
||||
typeValues := strings.Split(featureRestrictions, ",")
|
||||
|
||||
for i := 0; i < len(typeValues); i++ {
|
||||
value, err := strconv.ParseInt(typeValues[i], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if uint64(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD) <= uint64(value) && uint64(value) <= uint64(USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
typeValue := uint64(1 << (value - 1))
|
||||
restrictions = restrictions | typeValue
|
||||
}
|
||||
}
|
||||
|
||||
return UserFeatureRestrictions(restrictions)
|
||||
}
|
||||
|
||||
// UserFeatureRestrictionType represents the restriction type of user features
|
||||
type UserFeatureRestrictionType uint64
|
||||
|
||||
// User Feature Restriction Type
|
||||
const (
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD UserFeatureRestrictionType = 1
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL UserFeatureRestrictionType = 2
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO UserFeatureRestrictionType = 3
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR UserFeatureRestrictionType = 4
|
||||
USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION UserFeatureRestrictionType = 5
|
||||
USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA UserFeatureRestrictionType = 6
|
||||
USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA UserFeatureRestrictionType = 7
|
||||
USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD UserFeatureRestrictionType = 8
|
||||
USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION UserFeatureRestrictionType = 9
|
||||
USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION UserFeatureRestrictionType = 10
|
||||
USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA UserFeatureRestrictionType = 11
|
||||
)
|
||||
|
||||
// String returns a textual representation of the restriction type of user features
|
||||
func (t UserFeatureRestrictionType) String() string {
|
||||
switch t {
|
||||
case USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD:
|
||||
return "Update Password"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL:
|
||||
return "Update Email"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO:
|
||||
return "Update Profile Basic Info"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR:
|
||||
return "Update Avatar"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION:
|
||||
return "Logout Other Session"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA:
|
||||
return "Enable Two-Factor Authentication"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA:
|
||||
return "Disable Enable Two-Factor Authentication"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD:
|
||||
return "Forget Password"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION:
|
||||
return "Import Transactions"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION:
|
||||
return "Export Transactions"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA:
|
||||
return "Clear All Data"
|
||||
default:
|
||||
return fmt.Sprintf("Invalid(%d)", int(t))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserFeatureRestrictionsAdd(t *testing.T) {
|
||||
var featureRestrictions UserFeatureRestrictions
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD)
|
||||
expectedValue := UserFeatureRestrictions(1)
|
||||
assert.Equal(t, expectedValue, featureRestrictions)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD)
|
||||
expectedValue = UserFeatureRestrictions(255)
|
||||
assert.Equal(t, expectedValue, featureRestrictions)
|
||||
}
|
||||
|
||||
func TestUserFeatureRestrictionsRemove(t *testing.T) {
|
||||
var featureRestrictions UserFeatureRestrictions
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION)
|
||||
expectedValue := UserFeatureRestrictions(1)
|
||||
assert.Equal(t, expectedValue, featureRestrictions)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA)
|
||||
expectedValue = UserFeatureRestrictions(153)
|
||||
assert.Equal(t, expectedValue, featureRestrictions)
|
||||
}
|
||||
|
||||
func TestUserFeatureRestrictionsContains(t *testing.T) {
|
||||
var featureRestrictions UserFeatureRestrictions
|
||||
assert.False(t, featureRestrictions.Contains(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD))
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD)
|
||||
assert.True(t, featureRestrictions.Contains(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD))
|
||||
assert.False(t, featureRestrictions.Contains(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO))
|
||||
}
|
||||
|
||||
func TestUserFeatureRestrictionsString(t *testing.T) {
|
||||
var featureRestrictions UserFeatureRestrictions
|
||||
expectedValue := ""
|
||||
actualValue := featureRestrictions.String()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD)
|
||||
expectedValue = "Update Password"
|
||||
actualValue = featureRestrictions.String()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD)
|
||||
expectedValue = "Update Password,Forget Password"
|
||||
actualValue = featureRestrictions.String()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA)
|
||||
expectedValue = "Update Password," +
|
||||
"Update Email," +
|
||||
"Update Profile Basic Info," +
|
||||
"Update Avatar," +
|
||||
"Logout Other Session," +
|
||||
"Enable Two-Factor Authentication," +
|
||||
"Disable Enable Two-Factor Authentication," +
|
||||
"Forget Password," +
|
||||
"Import Transactions," +
|
||||
"Export Transactions," +
|
||||
"Clear All Data"
|
||||
actualValue = featureRestrictions.String()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestParseUserFeatureRestrictions(t *testing.T) {
|
||||
expectedValue := UserFeatureRestrictions(0)
|
||||
actualValue := ParseUserFeatureRestrictions("")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = UserFeatureRestrictions(1)
|
||||
actualValue = ParseUserFeatureRestrictions("1")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = UserFeatureRestrictions(1)
|
||||
actualValue = ParseUserFeatureRestrictions("1,20")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = UserFeatureRestrictions(255)
|
||||
actualValue = ParseUserFeatureRestrictions("1,2,3,4,5,6,7,8,20,21,22")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = UserFeatureRestrictions(255)
|
||||
actualValue = ParseUserFeatureRestrictions("1,2,3,4,5,6,7,8,a,b,20")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
@@ -19,4 +19,7 @@ var (
|
||||
ErrDestinationAccountNotFound = NewNormalError(NormalSubcategoryAccount, 12, http.StatusBadRequest, "destination account not found")
|
||||
ErrAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 13, http.StatusBadRequest, "account is in use and cannot be deleted")
|
||||
ErrAccountCategoryInvalid = NewNormalError(NormalSubcategoryAccount, 14, http.StatusBadRequest, "account category is invalid")
|
||||
ErrAccountBalanceTimeNotSet = NewNormalError(NormalSubcategoryAccount, 15, http.StatusBadRequest, "account balance time is not set")
|
||||
ErrCannotSetStatementDateForNonCreditCard = NewNormalError(NormalSubcategoryAccount, 16, http.StatusBadRequest, "cannot set statement date for non credit card account")
|
||||
ErrCannotSetStatementDateForSubAccount = NewNormalError(NormalSubcategoryAccount, 17, http.StatusBadRequest, "cannot set statement date for sub account")
|
||||
)
|
||||
|
||||
+36
-3
@@ -2,25 +2,58 @@ package errs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestErrorError(t *testing.T) {
|
||||
err := New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "error message")
|
||||
assert.EqualError(t, err, "error message")
|
||||
}
|
||||
|
||||
func TestErrorCode(t *testing.T) {
|
||||
err := New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "error message")
|
||||
assert.Equal(t, int32(112034), err.Code())
|
||||
}
|
||||
|
||||
func TestMultiError(t *testing.T) {
|
||||
err1 := errors.New("error1 message")
|
||||
err2 := errors.New("error2 message")
|
||||
err := NewMultiErrorOrNil(err1, err2)
|
||||
assert.Equal(t, "multi errors: error1 message, error2 message", err.Error())
|
||||
assert.EqualError(t, err, "multi errors: error1 message, error2 message")
|
||||
}
|
||||
|
||||
func TestNewMultiErrorOrNilWithOnlyOneParamerter(t *testing.T) {
|
||||
func TestNewMultiErrorOrNilWithOnlyOneParameter(t *testing.T) {
|
||||
err1 := errors.New("error1 message")
|
||||
err := NewMultiErrorOrNil(err1)
|
||||
assert.Equal(t, err1, err)
|
||||
assert.EqualError(t, err, "error1 message")
|
||||
}
|
||||
|
||||
func TestNewMultiErrorOrNilWithoutOneParamerter(t *testing.T) {
|
||||
func TestNewMultiErrorOrNilWithoutOneParameter(t *testing.T) {
|
||||
err := NewMultiErrorOrNil()
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestOr(t *testing.T) {
|
||||
err1 := errors.New("test error")
|
||||
err2 := New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "test custom error")
|
||||
err := Or(err1, err2)
|
||||
assert.Equal(t, err2, err)
|
||||
assert.EqualError(t, err, "test custom error")
|
||||
|
||||
err1 = New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "test custom error1")
|
||||
err2 = New(CATEGORY_SYSTEM, 23, 45, http.StatusInternalServerError, "test custom error2")
|
||||
err = Or(err1, err2)
|
||||
assert.Equal(t, err1, err)
|
||||
assert.EqualError(t, err, "test custom error1")
|
||||
}
|
||||
|
||||
func TestIsCustomError(t *testing.T) {
|
||||
err1 := errors.New("test error")
|
||||
err2 := New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "test custom error")
|
||||
assert.False(t, IsCustomError(err1))
|
||||
assert.True(t, IsCustomError(err2))
|
||||
}
|
||||
|
||||
@@ -37,4 +37,5 @@ var (
|
||||
ErrUserAvatarNotSet = NewNormalError(NormalSubcategoryUser, 28, http.StatusNotFound, "user avatar not set")
|
||||
ErrUserAvatarExtensionInvalid = NewNormalError(NormalSubcategoryUser, 29, http.StatusNotFound, "user avatar file extension invalid")
|
||||
ErrExceedMaxUserAvatarFileSize = NewNormalError(NormalSubcategoryUser, 30, http.StatusBadRequest, "exceed the maximum size of user avatar file")
|
||||
ErrNotPermittedToPerformThisAction = NewNormalError(NormalSubcategoryUser, 31, http.StatusBadRequest, "not permitted to perform this action")
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package exchangerates
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -129,9 +130,15 @@ func (e *BankOfCanadaExchangeRateData) ToLatestExchangeRateResponse(c core.Conte
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the bank of Canada data source urls
|
||||
func (e *BankOfCanadaDataSource) GetRequestUrls() []string {
|
||||
return []string{bankOfCanadaExchangeRateUrl}
|
||||
// BuildRequests returns the bank of Canada exchange rates http requests
|
||||
func (e *BankOfCanadaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", bankOfCanadaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the bank of Canada data source raw response
|
||||
|
||||
@@ -38,6 +38,15 @@ func TestBankOfCanadaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
assert.Equal(t, "CAD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestBankOfCanadaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &BankOfCanadaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfCanadaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617309000), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestBankOfCanadaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &BankOfCanadaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -172,4 +181,17 @@ func TestBankOfCanadaDataSource_InvalidRate(t *testing.T) {
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("{"+
|
||||
" \"observations\": [\n"+
|
||||
" {\n"+
|
||||
" \"d\": \"2021-04-01\",\n"+
|
||||
" \"FXUSDCAD\": {\n"+
|
||||
" \"v\": \"0\"\n"+
|
||||
" }\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"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 = "בנק ישראל"
|
||||
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
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
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(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[bank_of_israel_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, "[bank_of_israel_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
if unit <= 0 {
|
||||
log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRate] unit is less or equal zero, 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 bank of Israel exchange rates http requests
|
||||
func (e *BankOfIsraelDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", bankOfIsraelExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
bankOfIsraelData := &BankOfIsraelExchangeRateData{}
|
||||
err := xmlDecoder.Decode(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
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const bankOfIsraelMinimumRequiredContent = "" +
|
||||
"<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n" +
|
||||
" <ExchangeRates>\n" +
|
||||
" <ExchangeRateResponseDTO>\n" +
|
||||
" <CurrentExchangeRate>3.733</CurrentExchangeRate>\n" +
|
||||
" <Key>USD</Key>\n" +
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n" +
|
||||
" <Unit>1</Unit>\n" +
|
||||
" </ExchangeRateResponseDTO>\n" +
|
||||
" <ExchangeRateResponseDTO>\n" +
|
||||
" <CurrentExchangeRate>2.4287</CurrentExchangeRate>\n" +
|
||||
" <Key>JPY</Key>\n" +
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n" +
|
||||
" <Unit>100</Unit>\n" +
|
||||
" </ExchangeRateResponseDTO>\n" +
|
||||
" </ExchangeRates>\n" +
|
||||
"</ExchangeRatesResponseCollectioDTO>"
|
||||
|
||||
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_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfIsraelMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731331565), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
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("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>1</CurrentExchangeRate>\n"+
|
||||
" <Key>XXX</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>1</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
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("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>1</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
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("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>null</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>1</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>0</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>1</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
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("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>1</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
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("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>1</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>null</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>1</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>0</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"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 bankOfRussiaExchangeRateUrl = "https://cbr.ru/scripts/XML_daily_eng.asp"
|
||||
const bankOfRussiaExchangeRateReferenceUrl = "https://www.cbr.ru/eng/currency_base/daily/"
|
||||
const bankOfRussiaDataSource = "Банк России"
|
||||
const bankOfRussiaBaseCurrency = "RUB"
|
||||
|
||||
const bankOfRussiaUpdateDateFormat = "02.01.2006 15:04"
|
||||
const bankOfRussiaUpdateDateTimezone = "Europe/Moscow"
|
||||
|
||||
// BankOfRussiaDataSource defines the structure of exchange rates data source of bank of Russia
|
||||
type BankOfRussiaDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// BankOfRussiaExchangeRateData represents the whole data from bank of Russia
|
||||
type BankOfRussiaExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"ValCurs"`
|
||||
Date string `xml:"Date,attr"`
|
||||
ExchangeRates []*BankOfRussiaExchangeRate `xml:"Valute"`
|
||||
}
|
||||
|
||||
// BankOfRussiaExchangeRate represents the exchange rate data from bank of Russia
|
||||
type BankOfRussiaExchangeRate struct {
|
||||
Currency string `xml:"CharCode"`
|
||||
Rate string `xml:"VunitRate"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from bank of Russia
|
||||
func (e *BankOfRussiaExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.ExchangeRates))
|
||||
|
||||
for i := 0; i < len(e.ExchangeRates); i++ {
|
||||
exchangeRate := e.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(bankOfRussiaUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", bankOfRussiaUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
updateDateTime := e.Date + " 15:30" // the Bank of Russia switches to setting official exchange rates of foreign currencies against the ruble as of 15:30 Moscow time.
|
||||
updateTime, err := time.ParseInLocation(bankOfRussiaUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: bankOfRussiaDataSource,
|
||||
ReferenceUrl: bankOfRussiaExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: bankOfRussiaBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from bank of Russia
|
||||
func (e *BankOfRussiaExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(strings.ReplaceAll(e.Rate, ",", "."))
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[bank_of_russia_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[bank_of_russia_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 1 / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the bank of Russia exchange rates http requests
|
||||
func (e *BankOfRussiaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", bankOfRussiaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the bank of Russia data source raw response
|
||||
func (e *BankOfRussiaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
bankOfRussiaData := &BankOfRussiaExchangeRateData{}
|
||||
err := xmlDecoder.Decode(bankOfRussiaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := bankOfRussiaData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const bankOfRussiaDataSourceMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"windows-1251\"?>\n" +
|
||||
"<ValCurs Date=\"16.11.2024\">\n" +
|
||||
" <Valute>\n" +
|
||||
" <CharCode>USD</CharCode>\n" +
|
||||
" <VunitRate>99,9971</VunitRate>\n" +
|
||||
" </Valute>\n" +
|
||||
" <Valute>\n" +
|
||||
" <CharCode>CNY</CharCode>\n" +
|
||||
" <VunitRate>13,7992</VunitRate>\n" +
|
||||
" </Valute>\n" +
|
||||
"</ValCurs>"
|
||||
|
||||
func TestBankOfRussiaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "RUB", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731760200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.010000290008410243",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "CNY",
|
||||
Rate: "0.07246796915763232",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_EmptyExchangeRatesDataset(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
"</ValCurs>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
" <Valute>\n"+
|
||||
" <CharCode>XXX</CharCode>\n"+
|
||||
" <VunitRate>1</VunitRate>\n"+
|
||||
" </Valute>\n"+
|
||||
"</ValCurs>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
" <Valute>\n"+
|
||||
" <CharCode>USD</CharCode>\n"+
|
||||
" <VunitRate></VunitRate>\n"+
|
||||
" </Valute>\n"+
|
||||
"</ValCurs>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
" <Valute>\n"+
|
||||
" <CharCode>USD</CharCode>\n"+
|
||||
" <VunitRate>null</VunitRate>\n"+
|
||||
" </Valute>\n"+
|
||||
"</ValCurs>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
" <Valute>\n"+
|
||||
" <CharCode>USD</CharCode>\n"+
|
||||
" <VunitRate>0</VunitRate>\n"+
|
||||
" </Valute>\n"+
|
||||
"</ValCurs>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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
|
||||
}
|
||||
|
||||
if unit <= 0 {
|
||||
log.Warnf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRate] unit is less or equal zero, 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(
|
||||
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRates xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\"/>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>")))
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 = "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">" +
|
||||
"<s:Body>" +
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">" +
|
||||
"<GetCurrentExchangeRatesResult>" +
|
||||
"<MNBCurrentExchangeRates>" +
|
||||
"<Day date=\"2024-11-15\">" +
|
||||
"<Rate unit=\"100\" curr=\"JPY\">247,46</Rate>" +
|
||||
"<Rate unit=\"1\" curr=\"USD\">384,48</Rate>" +
|
||||
"</Day>" +
|
||||
"</MNBCurrentExchangeRates>" +
|
||||
"</GetCurrentExchangeRatesResult>" +
|
||||
"</GetCurrentExchangeRatesResponse>" +
|
||||
"</s:Body>" +
|
||||
"</s:Envelope>"
|
||||
|
||||
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("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_MissingGetCurrentExchangeRatesResponse(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_MissingGetCurrentExchangeRatesResult(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_EmptyGetCurrentExchangeRatesResult(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_InvalidGetCurrentExchangeRatesResult(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"1\" curr=\"XXX\">1</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
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("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"1\" curr=\"USD\"></Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
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("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"1\" curr=\"USD\">null</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"1\" curr=\"USD\">0</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
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("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"null\" curr=\"USD\">384,48</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"0\" curr=\"USD\">384,48</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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 centralBankOfMyanmarExchangeRateUrl = "https://forex.cbm.gov.mm/api/latest"
|
||||
const centralBankOfMyanmarExchangeRateReferenceUrl = "https://forex.cbm.gov.mm/index.php/fxrate"
|
||||
const centralBankOfMyanmarDataSource = "မြန်မာနိုင်ငံတော်ဗဟိုဘဏ်"
|
||||
const centralBankOfMyanmarBaseCurrency = "MMK"
|
||||
|
||||
var centralBankOfMyanmarSpecialCurrencyUnits = map[string]int32{
|
||||
"JPY": 100,
|
||||
"KHR": 100,
|
||||
"IDR": 100,
|
||||
"KRW": 100,
|
||||
"LAK": 100,
|
||||
"VND": 100,
|
||||
}
|
||||
|
||||
// CentralBankOfMyanmarDataSource defines the structure of exchange rates data source of central bank of Myanmar
|
||||
type CentralBankOfMyanmarDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// CentralBankOfMyanmarExchangeRate represents the exchange rate data from central bank of Myanmar
|
||||
type CentralBankOfMyanmarExchangeRate struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
ExchangeRates map[string]string `json:"rates"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from central bank of Myanmar
|
||||
func (e *CentralBankOfMyanmarExchangeRate) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.ExchangeRates))
|
||||
|
||||
for currencyCode, exchangeRate := range e.ExchangeRates {
|
||||
if _, exists := validators.AllCurrencyNames[currencyCode]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
finalExchangeRate := e.BuildLatestExchangeRate(c, currencyCode, exchangeRate)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
updateTime, err := utils.StringToInt64(e.Timestamp)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_myanmar_datasource.ToLatestExchangeRateResponse] failed to parse timestamp, timestamp is %s", e.Timestamp)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: centralBankOfMyanmarDataSource,
|
||||
ReferenceUrl: centralBankOfMyanmarExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime,
|
||||
BaseCurrency: centralBankOfMyanmarBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// BuildLatestExchangeRate returns a data pair according to original data from central bank of Myanmar
|
||||
func (e *CentralBankOfMyanmarExchangeRate) BuildLatestExchangeRate(c core.Context, currencyCode string, exchangeRate string) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(strings.ReplaceAll(exchangeRate, ",", ""))
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[central_bank_of_myanmar_datasource.BuildLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", currencyCode, exchangeRate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[central_bank_of_myanmar_datasource.BuildLatestExchangeRate] rate is invalid, currency is %s, rate is %s", currencyCode, exchangeRate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit, has := centralBankOfMyanmarSpecialCurrencyUnits[currencyCode]
|
||||
|
||||
if !has {
|
||||
unit = 1
|
||||
}
|
||||
|
||||
finalRate := float64(unit) / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: currencyCode,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the central bank of Myanmar exchange rates http requests
|
||||
func (e *CentralBankOfMyanmarDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", centralBankOfMyanmarExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the central bank of Myanmar data source raw response
|
||||
func (e *CentralBankOfMyanmarDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
centralBankOfMyanmarData := &CentralBankOfMyanmarExchangeRate{}
|
||||
err := json.Unmarshal(content, centralBankOfMyanmarData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_myanmar_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := centralBankOfMyanmarData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[central_bank_of_myanmar_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const centralBankOfMyanmarMinimumRequiredContent = "{\n" +
|
||||
" \"timestamp\": \"1731571200\",\n" +
|
||||
" \"rates\": {\n" +
|
||||
" \"USD\": \"2,100.0\",\n" +
|
||||
" \"JPY\": \"1,347.6\"\n" +
|
||||
" }\n" +
|
||||
"}"
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfMyanmarMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "MMK", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfMyanmarMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731571200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfMyanmarMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "0.07420599584446423",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.0004761904761904762",
|
||||
})
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_EmptyData(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("{}"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_EmptyExchangeRatesData(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\"\n"+
|
||||
"}"))
|
||||
|
||||
_, err = dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\",\n"+
|
||||
" \"rates\": {\n"+
|
||||
" }\n"+
|
||||
"}"))
|
||||
assert.Nil(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\",\n"+
|
||||
" \"rates\": {\n"+
|
||||
" \"XXX\": \"1\"\n"+
|
||||
" }\n"+
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\",\n"+
|
||||
" \"rates\": {\n"+
|
||||
" \"USD\": null\n"+
|
||||
" }\n"+
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\",\n"+
|
||||
" \"rates\": {\n"+
|
||||
" \"USD\": \"0\"\n"+
|
||||
" }\n"+
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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 centralBankOfUzbekistanExchangeRateUrl = "https://cbu.uz/ru/arkhiv-kursov-valyut/json/"
|
||||
const centralBankOfUzbekistanExchangeRateReferenceUrl = "https://cbu.uz/en/arkhiv-kursov-valyut/"
|
||||
const centralBankOfUzbekistanDataSource = "O‘zbekiston Respublikasi Markaziy banki"
|
||||
const centralBankOfUzbekistanBaseCurrency = "UZS"
|
||||
|
||||
const centralBankOfUzbekistanUpdateDateFormat = "02.01.2006"
|
||||
const centralBankOfUzbekistanUpdateDateTimezone = "Asia/Samarkand"
|
||||
|
||||
// CentralBankOfUzbekistanDataSource defines the structure of exchange rates data source of the central bank of the Republic of Uzbekistan
|
||||
type CentralBankOfUzbekistanDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// CentralBankOfUzbekistanExchangeRates represents the exchange rates data from the central bank of the Republic of Uzbekistan
|
||||
type CentralBankOfUzbekistanExchangeRates []*CentralBankOfUzbekistanExchangeRate
|
||||
|
||||
// CentralBankOfUzbekistanExchangeRate represents the exchange rate data from the central bank of the Republic of Uzbekistan
|
||||
type CentralBankOfUzbekistanExchangeRate struct {
|
||||
Currency string `json:"Ccy"`
|
||||
Unit string `json:"Nominal"`
|
||||
Rate string `json:"Rate"`
|
||||
Date string `json:"Date"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the central bank of the Republic of Uzbekistan
|
||||
func (e CentralBankOfUzbekistanExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e) < 1 {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(centralBankOfUzbekistanUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", danmarksNationalbankDataUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e))
|
||||
latestUpdateTime := int64(0)
|
||||
|
||||
for i := 0; i < len(e); i++ {
|
||||
exchangeRate := e[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTime, err := time.ParseInLocation(centralBankOfUzbekistanUpdateDateFormat, exchangeRate.Date, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_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: centralBankOfUzbekistanDataSource,
|
||||
ReferenceUrl: centralBankOfUzbekistanExchangeRateReferenceUrl,
|
||||
UpdateTime: latestUpdateTime,
|
||||
BaseCurrency: centralBankOfUzbekistanBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from the central bank of the Republic of Uzbekistan
|
||||
func (e *CentralBankOfUzbekistanExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[central_bank_of_uzbekistan_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_uzbekistan_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_uzbekistan_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
if unit <= 0 {
|
||||
log.Warnf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRate] unit is less or equal zero, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 1000 * unit / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the the central bank of the Republic of Uzbekistan exchange rates http requests
|
||||
func (e *CentralBankOfUzbekistanDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", centralBankOfUzbekistanExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the the central bank of the Republic of Uzbekistan data source raw response
|
||||
func (e *CentralBankOfUzbekistanDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
centralBankOfUzbekistanData := &CentralBankOfUzbekistanExchangeRates{}
|
||||
err := json.Unmarshal(content, centralBankOfUzbekistanData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := centralBankOfUzbekistanData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const centralBankOfUzbekistanMinimumRequiredContent = "[\n" +
|
||||
" {\n" +
|
||||
" \"Ccy\": \"USD\",\n" +
|
||||
" \"Nominal\": \"1\",\n" +
|
||||
" \"Rate\": \"12800.13\",\n" +
|
||||
" \"Date\": \"15.11.2024\"\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"Ccy\": \"VND\",\n" +
|
||||
" \"Nominal\": \"10\",\n" +
|
||||
" \"Rate\": \"5.04\",\n" +
|
||||
" \"Date\": \"15.11.2024\"\n" +
|
||||
" }\n" +
|
||||
"]"
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfUzbekistanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "UZS", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfUzbekistanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731610800), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfUzbekistanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.07812420655102723",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "VND",
|
||||
Rate: "1984.126984126984",
|
||||
})
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_EmptyData(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("[]"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"XXX\",\n"+
|
||||
" \"Nominal\": \"1\",\n"+
|
||||
" \"Rate\": \"1\",\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_InvalidNominal(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"USD\",\n"+
|
||||
" \"Nominal\": null,\n"+
|
||||
" \"Rate\": \"12800.13\",\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"USD\",\n"+
|
||||
" \"Nominal\": \"0\",\n"+
|
||||
" \"Rate\": \"12800.13\",\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"USD\",\n"+
|
||||
" \"Nominal\": \"1\",\n"+
|
||||
" \"Rate\": null,\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"USD\",\n"+
|
||||
" \"Nominal\": \"1\",\n"+
|
||||
" \"Rate\": \"0\",\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package exchangerates
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,9 +28,21 @@ type CzechNationalBankDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the czech nation bank data source urls
|
||||
func (e *CzechNationalBankDataSource) GetRequestUrls() []string {
|
||||
return []string{czechNationalBankMonthlyOtherExchangeRateUrl, czechNationalBankDailyExchangeRateUrl}
|
||||
// BuildRequests returns the Czech National Bank exchange rates http requests
|
||||
func (e *CzechNationalBankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
monthlyReq, err := http.NewRequest("GET", czechNationalBankMonthlyOtherExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dailyReq, err := http.NewRequest("GET", czechNationalBankDailyExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{monthlyReq, dailyReq}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the czech nation bank data source raw response
|
||||
@@ -140,6 +153,11 @@ func (e *CzechNationalBankDataSource) parseExchangeRate(c core.Context, line str
|
||||
return nil
|
||||
}
|
||||
|
||||
if amount <= 0 {
|
||||
log.Warnf(c, "[czech_national_bank_datasource.parseExchangeRate] amount is invalid, line is %s", line)
|
||||
return nil
|
||||
}
|
||||
|
||||
rate, err := utils.StringToFloat64(items[rateColumnIndex])
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -23,6 +23,15 @@ func TestCzechNationalBankDataSource_StandardDataExtractBaseCurrency(t *testing.
|
||||
assert.Equal(t, "CZK", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(czechNationalBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617280200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -64,6 +73,16 @@ func TestCzechNationalBankDataSource_OnlyHeaderAndTitle(t *testing.T) {
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_MissingHeader(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("Country|Currency|Amount|Code|Rate\n"+
|
||||
"China|renminbi|1|CNY|3.379\n"+
|
||||
"USA|dollar|1|USD|22.206\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_TitleMissingCode(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -75,6 +94,17 @@ func TestCzechNationalBankDataSource_TitleMissingCode(t *testing.T) {
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_TitleMissingAmount(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Code|Rate\n"+
|
||||
"China|renminbi|CNY|3.379\n"+
|
||||
"USA|dollar|USD|22.206\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_TitleMissingRate(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -86,6 +116,17 @@ func TestCzechNationalBankDataSource_TitleMissingRate(t *testing.T) {
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_MissingDataItem(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Amount|Code|Rate\n"+
|
||||
"USA|dollar|1|USD\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -97,6 +138,23 @@ func TestCzechNationalBankDataSource_InvalidCurrency(t *testing.T) {
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_InvalidAmount(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Amount|Code|Rate\n"+
|
||||
"USA|dollar|null|USD|22.206\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Amount|Code|Rate\n"+
|
||||
"USA|dollar|0|USD|22.206\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -117,4 +175,10 @@ func TestCzechNationalBankDataSource_InvalidRate(t *testing.T) {
|
||||
"USA|dollar|1|USD|null\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Amount|Code|Rate\n"+
|
||||
"USA|dollar|1|USD|0\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"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 danmarksNationalbankExchangeRateUrl = "https://www.nationalbanken.dk/api/currencyratesxml?lang=en"
|
||||
const danmarksNationalbankExchangeRateReferenceUrl = "https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates"
|
||||
const danmarksNationalbankDataSource = "Danmarks Nationalbank"
|
||||
|
||||
const danmarksNationalbankDataUpdateDateFormat = "2006-01-02 15"
|
||||
const danmarksNationalbankDataUpdateDateTimezone = "Europe/Copenhagen"
|
||||
|
||||
// DanmarksNationalbankDataSource defines the structure of exchange rates data source of Danmarks Nationalbank
|
||||
type DanmarksNationalbankDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// DanmarksNationalbankExchangeRateData represents the whole data from Danmarks Nationalbank
|
||||
type DanmarksNationalbankExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"exchangerates"`
|
||||
DailyExchangeRates []*DanmarksNationalbankDailyExchangeRates `xml:"dailyrates"`
|
||||
BaseCurrency string `xml:"refcur,attr"`
|
||||
}
|
||||
|
||||
// DanmarksNationalbankDailyExchangeRates represents the exchange rates data from Danmarks Nationalbank
|
||||
type DanmarksNationalbankDailyExchangeRates struct {
|
||||
Date string `xml:"id,attr"`
|
||||
ExchangeRates []*DanmarksNationalbankExchangeRate `xml:"currency"`
|
||||
}
|
||||
|
||||
// DanmarksNationalbankExchangeRate represents the exchange rate data from Danmarks Nationalbank
|
||||
type DanmarksNationalbankExchangeRate struct {
|
||||
Currency string `xml:"code,attr"`
|
||||
Rate string `xml:"rate,attr"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from Danmarks Nationalbank
|
||||
func (e *DanmarksNationalbankExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e.DailyExchangeRates) < 1 {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRateResponse] daily exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestDanmarksNationalbankExchangeRate := e.DailyExchangeRates[0]
|
||||
|
||||
if len(latestDanmarksNationalbankExchangeRate.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(latestDanmarksNationalbankExchangeRate.ExchangeRates))
|
||||
|
||||
for i := 0; i < len(latestDanmarksNationalbankExchangeRate.ExchangeRates); i++ {
|
||||
exchangeRate := latestDanmarksNationalbankExchangeRate.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(danmarksNationalbankDataUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", danmarksNationalbankDataUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
updateDateTime := latestDanmarksNationalbankExchangeRate.Date + " 16" // ECB publishes the reference rates determined at the concertation at 16:00 and shortly after Danmarks Nationalbank publishes the prices in Danish kroner
|
||||
updateTime, err := time.ParseInLocation(danmarksNationalbankDataUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: danmarksNationalbankDataSource,
|
||||
ReferenceUrl: danmarksNationalbankExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: e.BaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from Danmarks Nationalbank
|
||||
func (e *DanmarksNationalbankExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 100 / rate // the latest exchange rates listed as the price in Danish kroner for 100 units of foreign currency
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the Danmarks Nationalbank exchange rates http requests
|
||||
func (e *DanmarksNationalbankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", danmarksNationalbankExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the Danmarks Nationalbank data source raw response
|
||||
func (e *DanmarksNationalbankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
danmarksNationalbankData := &DanmarksNationalbankExchangeRateData{}
|
||||
err := xmlDecoder.Decode(danmarksNationalbankData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := danmarksNationalbankData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const danmarksNationalbankMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
"<exchangerates refcur=\"DKK\">\n" +
|
||||
" <dailyrates id=\"2024-11-14\">\n" +
|
||||
" <currency code=\"CNY\" rate=\"97.81\" />\n" +
|
||||
" <currency code=\"USD\" rate=\"708.18\" />\n" +
|
||||
" </dailyrates>\n" +
|
||||
"</exchangerates>"
|
||||
|
||||
func TestDanmarksNationalbankDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(danmarksNationalbankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "DKK", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(danmarksNationalbankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731596400), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(danmarksNationalbankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.1412070377587619",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "CNY",
|
||||
Rate: "1.022390348635109",
|
||||
})
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_EmptyExchangeRatesContent(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
"</exchangerates>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_EmptyDailyRatesContent(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
"<dailyrates id=\"2024-11-14\">"+
|
||||
"</dailyrates>"+
|
||||
"</exchangerates>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
" <dailyrates id=\"2024-11-14\">\n"+
|
||||
" <currency code=\"XXX\" desc=\"XXX\" rate=\"1\" />\n"+
|
||||
" </dailyrates>\n"+
|
||||
"</exchangerates>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
" <dailyrates id=\"2024-11-14\">\n"+
|
||||
" <currency code=\"USD\" rate=\"\" />\n"+
|
||||
" </dailyrates>\n"+
|
||||
"</exchangerates>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
" <dailyrates id=\"2024-11-14\">\n"+
|
||||
" <currency code=\"USD\" rate=\"null\" />\n"+
|
||||
" </dailyrates>\n"+
|
||||
"</exchangerates>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
" <dailyrates id=\"2024-11-14\">\n"+
|
||||
" <currency code=\"USD\" rate=\"0\" />\n"+
|
||||
" </dailyrates>\n"+
|
||||
"</exchangerates>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
@@ -107,15 +111,24 @@ func (e *EuroCentralBankExchangeRate) ToLatestExchangeRate() *models.LatestExcha
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the euro central bank data source urls
|
||||
func (e *EuroCentralBankDataSource) GetRequestUrls() []string {
|
||||
return []string{euroCentralBankExchangeRateUrl}
|
||||
// BuildRequests returns the euro central bank exchange rates http requests
|
||||
func (e *EuroCentralBankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", euroCentralBankExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the euro central bank data source raw response
|
||||
func (e *EuroCentralBankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
euroCentralBankData := &EuroCentralBankExchangeRateData{}
|
||||
err := xml.Unmarshal(content, euroCentralBankData)
|
||||
err := xmlDecoder.Decode(euroCentralBankData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[euro_central_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
|
||||
@@ -28,6 +28,15 @@ func TestEuroCentralBankDataSource_StandardDataExtractBaseCurrency(t *testing.T)
|
||||
assert.Equal(t, "EUR", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestEuroCentralBankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &EuroCentralBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(euroCentralBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617285600), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestEuroCentralBankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &EuroCentralBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
// ExchangeRatesDataSource defines the structure of exchange rates data source
|
||||
type ExchangeRatesDataSource interface {
|
||||
// GetRequestUrl returns the data source urls
|
||||
GetRequestUrls() []string
|
||||
// BuildRequests returns the http requests
|
||||
BuildRequests() ([]*http.Request, error)
|
||||
|
||||
// Parse returns the common response entity according to the data source raw response
|
||||
Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error)
|
||||
|
||||
@@ -17,21 +17,51 @@ var (
|
||||
|
||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
||||
Container.Current = &EuroCentralBankDataSource{}
|
||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.Current = &ReserveBankOfAustraliaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
Container.Current = &BankOfCanadaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.Current = &ReserveBankOfAustraliaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||
Container.Current = &CzechNationalBankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
|
||||
Container.Current = &DanmarksNationalbankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
||||
Container.Current = &EuroCentralBankDataSource{}
|
||||
return nil
|
||||
} 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
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
||||
Container.Current = &CentralBankOfMyanmarDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
|
||||
Container.Current = &NorgesBankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
|
||||
Container.Current = &NationalBankOfPolandDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
|
||||
Container.Current = &NationalBankOfRomaniaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
|
||||
Container.Current = &BankOfRussiaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
|
||||
Container.Current = &SwissNationalBankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
|
||||
Container.Current = &CentralBankOfUzbekistanDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
|
||||
Container.Current = &InternationalMonetaryFundDataSource{}
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -71,9 +72,15 @@ func init() {
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Uruguayan peso"] = "UYU"
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the international monetary fund data source urls
|
||||
func (e *InternationalMonetaryFundDataSource) GetRequestUrls() []string {
|
||||
return []string{internationalMonetaryFundExchangeRateUrl}
|
||||
// BuildRequests returns the international monetary fund exchange rates http requests
|
||||
func (e *InternationalMonetaryFundDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", internationalMonetaryFundExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the international monetary fund data source raw response
|
||||
|
||||
@@ -26,6 +26,15 @@ func TestInternationalMonetaryFundDataSource_StandardDataExtractBaseCurrency(t *
|
||||
assert.Equal(t, "USD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(internationalMonetaryFundMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1724857200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -95,6 +104,20 @@ func TestInternationalMonetaryFundDataSource_MissingDefaultCurrencyData(t *testi
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_DefaultCurrencyDataInvalid(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t0.1040520000\t0.1039250000\t0.1040370000\t0.1040850000\t0.1040570000\n"+
|
||||
"U.S. dollar\t0\t0\t0\t0\t0\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -110,6 +133,41 @@ func TestInternationalMonetaryFundDataSource_InvalidCurrency(t *testing.T) {
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\tnull\tnull\tnull\tnull\tnull\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t0\t0\t0\t0\t0\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t\t\t\t\t\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_LatestDateNotHasRate(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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 nationalBankOfGeorgiaExchangeRateUrl = "https://nbg.gov.ge/gw/api/ct/monetarypolicy/currencies/en/json"
|
||||
const nationalBankOfGeorgiaExchangeRateReferenceUrl = "https://nbg.gov.ge/en/monetary-policy/currency"
|
||||
const nationalBankOfGeorgiaDataSource = "საქართველოს ეროვნული ბანკი"
|
||||
const nationalBankOfGeorgiaBaseCurrency = "GEL"
|
||||
|
||||
const nationalBankOfGeorgiaUpdateDateFormat = "2006-01-02T15:04:05.999Z"
|
||||
|
||||
// NationalBankOfGeorgiaDataSource defines the structure of exchange rates data source of national bank of Georgia
|
||||
type NationalBankOfGeorgiaDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// NationalBankOfGeorgiaExchangeRates represents the exchange rates data from national bank of Georgia
|
||||
type NationalBankOfGeorgiaExchangeRates struct {
|
||||
Date string `json:"date"`
|
||||
ExchangeRates []*NationalBankOfGeorgiaExchangeRate `json:"currencies"`
|
||||
}
|
||||
|
||||
// NationalBankOfGeorgiaExchangeRate represents the exchange rate data from national bank of Georgia
|
||||
type NationalBankOfGeorgiaExchangeRate struct {
|
||||
Currency string `json:"code"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Rate float64 `json:"rate"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from national bank of Georgia
|
||||
func (e *NationalBankOfGeorgiaExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.ExchangeRates))
|
||||
latestUpdateTime := int64(0)
|
||||
|
||||
for i := 0; i < len(e.ExchangeRates); i++ {
|
||||
exchangeRate := e.ExchangeRates[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTime, err := time.Parse(nationalBankOfGeorgiaUpdateDateFormat, exchangeRate.Date)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_georgia_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: nationalBankOfGeorgiaDataSource,
|
||||
ReferenceUrl: nationalBankOfGeorgiaExchangeRateReferenceUrl,
|
||||
UpdateTime: latestUpdateTime,
|
||||
BaseCurrency: nationalBankOfGeorgiaBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from national bank of Georgia
|
||||
func (e *NationalBankOfGeorgiaExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
if e.Rate <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_georgia_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_georgia_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 Georgia exchange rates http requests
|
||||
func (e *NationalBankOfGeorgiaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", nationalBankOfGeorgiaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the national bank of Georgia data source raw response
|
||||
func (e *NationalBankOfGeorgiaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
nationalBankOfGeorgiaData := &[]*NationalBankOfGeorgiaExchangeRates{}
|
||||
err := json.Unmarshal(content, nationalBankOfGeorgiaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if nationalBankOfGeorgiaData == nil || len(*nationalBankOfGeorgiaData) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := (*nationalBankOfGeorgiaData)[0].ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const nationalBankOfGeorgiaMinimumRequiredContent = "[\n" +
|
||||
" {\n" +
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n" +
|
||||
" \"currencies\": [\n" +
|
||||
" {\n" +
|
||||
" \"code\": \"JPY\",\n" +
|
||||
" \"quantity\": 100,\n" +
|
||||
" \"rate\": 1.7589,\n" +
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"code\": \"USD\",\n" +
|
||||
" \"quantity\": 1,\n" +
|
||||
" \"rate\": 2.7311,\n" +
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" }\n" +
|
||||
"]"
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfGeorgiaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "GEL", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfGeorgiaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731690071), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfGeorgiaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "56.853715390300756",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.366152832192157",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_EmptyData(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("[]"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_EmptyExchangeRatesData(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("[{}]"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
|
||||
_, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"XXX\",\n"+
|
||||
" \"quantity\": 1,\n"+
|
||||
" \"rate\": 1,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_InvalidQuantity(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"USD\",\n"+
|
||||
" \"quantity\": null,\n"+
|
||||
" \"rate\": 2.7311,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"USD\",\n"+
|
||||
" \"quantity\": 0,\n"+
|
||||
" \"rate\": 2.7311,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"USD\",\n"+
|
||||
" \"quantity\": 1,\n"+
|
||||
" \"rate\": null,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"USD\",\n"+
|
||||
" \"quantity\": 1,\n"+
|
||||
" \"rate\": 0,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
@@ -125,13 +126,30 @@ func (e *NationalBankOfPolandDataSource) GetRequestUrls() []string {
|
||||
return []string{nationalBankOfPolandInconvertibleCurrencyExchangeRateUrl, nationalBankOfPolandDailyExchangeRateUrl}
|
||||
}
|
||||
|
||||
// BuildRequests returns the national bank of Poland exchange rates http requests
|
||||
func (e *NationalBankOfPolandDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
inconvertibleCurrencyReq, err := http.NewRequest("GET", nationalBankOfPolandInconvertibleCurrencyExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dailyReq, err := http.NewRequest("GET", nationalBankOfPolandDailyExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{inconvertibleCurrencyReq, dailyReq}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the National Bank of Poland data source raw response
|
||||
func (e *NationalBankOfPolandDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
nationalBankOfPolandData := &NationalBankOfPolandExchangeRateData{}
|
||||
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
err := xmlDecoder.Decode(&nationalBankOfPolandData)
|
||||
|
||||
nationalBankOfPolandData := &NationalBankOfPolandExchangeRateData{}
|
||||
err := xmlDecoder.Decode(nationalBankOfPolandData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_poland_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
|
||||
@@ -35,6 +35,15 @@ func TestNationalBankOfPolandDataSource_StandardDataExtractBaseCurrency(t *testi
|
||||
assert.Equal(t, "PLN", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNationalBankOfPolandDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NationalBankOfPolandDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfPolandMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1709118900), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNationalBankOfPolandDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NationalBankOfPolandDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -162,4 +171,19 @@ func TestNationalBankOfPolandDataSource_InvalidRate(t *testing.T) {
|
||||
"</ArrayOfExchangeRatesTable>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"+
|
||||
"<ArrayOfExchangeRatesTable xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">\n"+
|
||||
" <ExchangeRatesTable>\n"+
|
||||
" <EffectiveDate>2024-02-28</EffectiveDate>\n"+
|
||||
" <Rates>\n"+
|
||||
" <Rate>\n"+
|
||||
" <Code>USD</Code>\n"+
|
||||
" <Mid>0</Mid>\n"+
|
||||
" </Rate>\n"+
|
||||
" </Rates>\n"+
|
||||
" </ExchangeRatesTable>\n"+
|
||||
"</ArrayOfExchangeRatesTable>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"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 nationalBankOfRomaniaExchangeRateUrl = "https://www.bnr.ro/nbrfxrates.xml"
|
||||
const nationalBankOfRomaniaExchangeRateReferenceUrl = "https://www.bnr.ro/Exchange-rates-1224.aspx"
|
||||
const nationalBankOfRomaniaDataSource = "Banca Naţională a României"
|
||||
|
||||
const nationalBankOfRomaniaUpdateDateFormat = "2006-01-02 15"
|
||||
const nationalBankOfRomaniaUpdateDateTimezone = "Europe/Bucharest"
|
||||
|
||||
// NationalBankOfRomaniaDataSource defines the structure of exchange rates data source of national bank of Romania
|
||||
type NationalBankOfRomaniaDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRateData represents the whole data from national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"DataSet"`
|
||||
Header *NationalBankOfRomaniaExchangeRateDataHeader `xml:"Header"`
|
||||
Body *NationalBankOfRomaniaExchangeRateDataBody `xml:"Body"`
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRateDataHeader represents the header for exchange rates data of national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRateDataHeader struct {
|
||||
PublishingDate string `xml:"PublishingDate"`
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRateDataBody represents the body for exchange rates data of national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRateDataBody struct {
|
||||
OrigCurrency string `xml:"OrigCurrency"`
|
||||
AllExchangeRates []*NationalBankOfRomaniaExchangeRates `xml:"Cube"`
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRates represents the exchange rates data from national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRates struct {
|
||||
Date string `xml:"date,attr"`
|
||||
ExchangeRates []*NationalBankOfRomaniaExchangeRate `xml:"Rate"`
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRate represents the exchange rate data from national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRate struct {
|
||||
Currency string `xml:"currency,attr"`
|
||||
Multiplier string `xml:"multiplier,attr"`
|
||||
Rate string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from national bank of Romania
|
||||
func (e *NationalBankOfRomaniaExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e.Header == nil || e.Body == nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] header or body is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(e.Body.AllExchangeRates) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestNationalBankOfRomaniaExchangeRate := e.Body.AllExchangeRates[0]
|
||||
|
||||
if len(latestNationalBankOfRomaniaExchangeRate.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(latestNationalBankOfRomaniaExchangeRate.ExchangeRates))
|
||||
|
||||
for i := 0; i < len(latestNationalBankOfRomaniaExchangeRate.ExchangeRates); i++ {
|
||||
exchangeRate := latestNationalBankOfRomaniaExchangeRate.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(nationalBankOfRomaniaUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", nationalBankOfRomaniaUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
updateDateTime := e.Header.PublishingDate + " 13" // The data are updated in real time, shortly after 13:00, every banking day.
|
||||
updateTime, err := time.ParseInLocation(nationalBankOfRomaniaUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: nationalBankOfRomaniaDataSource,
|
||||
ReferenceUrl: nationalBankOfRomaniaExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: e.Body.OrigCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from national bank of Romania
|
||||
func (e *NationalBankOfRomaniaExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit := float64(1)
|
||||
|
||||
if e.Multiplier != "" {
|
||||
unit, err = utils.StringToFloat64(e.Multiplier)
|
||||
|
||||
if err != nil || unit <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit is %s", e.Currency, e.Multiplier)
|
||||
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 national bank of Romania exchange rates http requests
|
||||
func (e *NationalBankOfRomaniaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", nationalBankOfRomaniaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the national bank of Romania data source raw response
|
||||
func (e *NationalBankOfRomaniaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
nationalBankOfRomaniaData := &NationalBankOfRomaniaExchangeRateData{}
|
||||
err := xmlDecoder.Decode(nationalBankOfRomaniaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := nationalBankOfRomaniaData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const nationalBankOfRomaniaMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">\n" +
|
||||
" <Header>\n" +
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n" +
|
||||
" </Header>\n" +
|
||||
" <Body>\n" +
|
||||
" <OrigCurrency>RON</OrigCurrency>\n" +
|
||||
" <Cube date=\"2024-11-15\">\n" +
|
||||
" <Rate currency=\"JPY\" multiplier=\"100\">3.0303</Rate>\n" +
|
||||
" <Rate currency=\"USD\">4.7057</Rate>\n" +
|
||||
" </Cube>\n" +
|
||||
" </Body>\n" +
|
||||
"</DataSet>"
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "RON", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731668400), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "33.000033000033",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.21250823469409438",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_EmptyExchangeRatesDataset(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
"</DataSet>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_NoDailyRatesHeader(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
"</DataSet>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_NoDailyRatesBody(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
"</DataSet>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_NoDailyRatesCube(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"XXX\">1</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"USD\"></Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"USD\">null</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"USD\">0</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_InvalidMultiplier(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"JPY\" multiplier=\"null\">3.0303</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"JPY\" multiplier=\"0\">3.0303</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"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 norgesBankExchangeRateUrl = "https://data.norges-bank.no/api/data/EXR/B..NOK.SP?format=sdmx-compact-2.1&lastNObservations=1"
|
||||
const norgesBankExchangeRateReferenceUrl = "https://www.norges-bank.no/en/topics/Statistics/exchange_rates/"
|
||||
const norgesBankDataSource = "Norges Bank"
|
||||
const norgesBankBaseCurrency = "NOK"
|
||||
|
||||
const norgesBankUpdateDateFormat = "2006-01-02 15"
|
||||
const norgesBankUpdateDateTimezone = "Europe/Oslo"
|
||||
|
||||
// NorgesBankDataSource defines the structure of exchange rates data source of Norges Bank
|
||||
type NorgesBankDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// NorgesBankExchangeRateData represents the whole data from Norges Bank
|
||||
type NorgesBankExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"StructureSpecificData"`
|
||||
DataSet *NorgesBankExchangeRateDataSet `xml:"DataSet"`
|
||||
}
|
||||
|
||||
// NorgesBankExchangeRateDataSet represents the dataset for exchange rates data of Norges Bank
|
||||
type NorgesBankExchangeRateDataSet struct {
|
||||
ExchangeRates []*NorgesBankExchangeRate `xml:"Series"`
|
||||
}
|
||||
|
||||
// NorgesBankExchangeRate represents the exchange rate data from Norges Bank
|
||||
type NorgesBankExchangeRate struct {
|
||||
BaseCurrency string `xml:"BASE_CUR,attr"`
|
||||
TargetCurrency string `xml:"QUOTE_CUR,attr"`
|
||||
UnitExponent string `xml:"UNIT_MULT,attr"`
|
||||
Observations []*NorgesBankExchangeRateObservation `xml:"Obs"`
|
||||
}
|
||||
|
||||
// NorgesBankExchangeRateObservation represents the observation data of exchange rate data from Norges Bank
|
||||
type NorgesBankExchangeRateObservation struct {
|
||||
Date string `xml:"TIME_PERIOD,attr"`
|
||||
Rate string `xml:"OBS_VALUE,attr"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from Norges Bank
|
||||
func (e *NorgesBankExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e.DataSet == nil || len(e.DataSet.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[norges_bank_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(norgesBankUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[norges_bank_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", norgesBankUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.DataSet.ExchangeRates))
|
||||
latestUpdateTime := int64(0)
|
||||
|
||||
for i := 0; i < len(e.DataSet.ExchangeRates); i++ {
|
||||
exchangeRate := e.DataSet.ExchangeRates[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.BaseCurrency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if exchangeRate.TargetCurrency != norgesBankBaseCurrency {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(exchangeRate.Observations) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
updateDateTime := exchangeRate.Observations[0].Date + " 16" // Publication time of daily exchange rates is approximately 16:00 CET.
|
||||
updateTime, err := time.ParseInLocation(norgesBankUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[norges_bank_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Observations[0].Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
if updateTime.Unix() > latestUpdateTime {
|
||||
latestUpdateTime = updateTime.Unix()
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c, exchangeRate.Observations[0].Rate)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: norgesBankDataSource,
|
||||
ReferenceUrl: norgesBankExchangeRateReferenceUrl,
|
||||
UpdateTime: latestUpdateTime,
|
||||
BaseCurrency: norgesBankBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from Norges Bank
|
||||
func (e *NorgesBankExchangeRate) ToLatestExchangeRate(c core.Context, exchangeRate string) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(exchangeRate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[norges_bank_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.BaseCurrency, exchangeRate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[norges_bank_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.BaseCurrency, exchangeRate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unitExponent, err := utils.StringToInt(e.UnitExponent)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[norges_bank_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit exponent is %s", e.BaseCurrency, e.UnitExponent)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 1 / rate
|
||||
|
||||
if unitExponent > 0 {
|
||||
finalRate = finalRate / math.Pow10(-unitExponent)
|
||||
} else if unitExponent < 0 {
|
||||
log.Warnf(c, "[norges_bank_datasource.ToLatestExchangeRate] unit exponent is less than zero, currency is %s, unit is %s", e.BaseCurrency, e.UnitExponent)
|
||||
return nil
|
||||
}
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.BaseCurrency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the Norges Bank exchange rates http requests
|
||||
func (e *NorgesBankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", norgesBankExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the Norges Bank data source raw response
|
||||
func (e *NorgesBankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
norgesBankData := &NorgesBankExchangeRateData{}
|
||||
err := xmlDecoder.Decode(norgesBankData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[norges_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := norgesBankData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[norges_bank_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const norgesBankOfRomaniaMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">\n" +
|
||||
" <message:DataSet>\n" +
|
||||
" <Series BASE_CUR=\"JPY\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"2\">\n" +
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"7.1179\" />\n" +
|
||||
" </Series>\n" +
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n" +
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n" +
|
||||
" </Series>\n" +
|
||||
" </message:DataSet>\n" +
|
||||
"</message:StructureSpecificData>"
|
||||
|
||||
func TestNorgesBankDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(norgesBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "NOK", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(norgesBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731682800), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(norgesBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "14.049087511766112",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.09046089827671988",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_MissingExchangeRatesDataset(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_EmptyExchangeRatesDataset(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_EmptyExchangeRateObservations(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"XXX\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"1\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_InvalidTargetCurrency(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"EUR\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"null\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"0\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_InvalidUnit(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"null\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"-1\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
@@ -122,15 +126,24 @@ func (e *ReserveBankOfAustraliaExchangeRate) ToLatestExchangeRate() *models.Late
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the the reserve bank of Australia data source urls
|
||||
func (e *ReserveBankOfAustraliaDataSource) GetRequestUrls() []string {
|
||||
return []string{reserveBankOfAustraliaExchangeRateUrl}
|
||||
// BuildRequests returns the reserve bank of Australia exchange rates http requests
|
||||
func (e *ReserveBankOfAustraliaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", reserveBankOfAustraliaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the the reserve bank of Australia data source raw response
|
||||
func (e *ReserveBankOfAustraliaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
reserveBankOfAustraliaData := &ReserveBankOfAustraliaData{}
|
||||
err := xml.Unmarshal(content, reserveBankOfAustraliaData)
|
||||
err := xmlDecoder.Decode(reserveBankOfAustraliaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
|
||||
@@ -49,6 +49,15 @@ func TestReserveBankOfAustraliaDataSource_StandardDataExtractBaseCurrency(t *tes
|
||||
assert.Equal(t, "AUD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617255900), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"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 swissNationalBankExchangeRateUrl = "https://www.snb.ch/public/en/rss/exchangeRates"
|
||||
const swissNationalBankExchangeRateReferenceUrl = "https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates"
|
||||
const swissNationalBankDataSource = "Schweizerische Nationalbank"
|
||||
const swissNationalBankBaseCurrency = "CHF"
|
||||
|
||||
const swissNationalBankDataUpdateDateFormat = "Mon, 02 Jan 2006 15:04:05 MST"
|
||||
const swissNationalBankExchangeRatePeriodDateFormat = "2006-01-02"
|
||||
|
||||
// SwissNationalBankDataSource defines the structure of exchange rates data source of the reserve Swiss National Bank
|
||||
type SwissNationalBankDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// SwissNationalBankData represents the whole data from the reserve Swiss National Bank
|
||||
type SwissNationalBankData struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Channel *SwissNationalBankRssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
// SwissNationalBankRssChannel represents the rss channel from the reserve Swiss National Bank
|
||||
type SwissNationalBankRssChannel struct {
|
||||
PublishDate string `xml:"pubDate"`
|
||||
Items []*SwissNationalBankChannelItem `xml:"item"`
|
||||
}
|
||||
|
||||
// SwissNationalBankChannelItem represents the channel item from the reserve Swiss National Bank
|
||||
type SwissNationalBankChannelItem struct {
|
||||
Statistics *SwissNationalBankItemStatistics `xml:"statistics"`
|
||||
}
|
||||
|
||||
// SwissNationalBankItemStatistics represents the item statistics from the reserve Swiss National Bank
|
||||
type SwissNationalBankItemStatistics struct {
|
||||
ExchangeRate *SwissNationalBankExchangeRate `xml:"exchangeRate"`
|
||||
}
|
||||
|
||||
// SwissNationalBankExchangeRate represents the exchange rate from the reserve Swiss National Bank
|
||||
type SwissNationalBankExchangeRate struct {
|
||||
BaseCurrency string `xml:"baseCurrency"`
|
||||
TargetCurrency string `xml:"targetCurrency"`
|
||||
Observation *SwissNationalBankExchangeRateObservation `xml:"observation"`
|
||||
ObservationPeriod *SwissNationalBankExchangeRateObservationPeriod `xml:"observationPeriod"`
|
||||
}
|
||||
|
||||
// SwissNationalBankExchangeRateObservation represents the exchange rate data from the reserve Swiss National Bank
|
||||
type SwissNationalBankExchangeRateObservation struct {
|
||||
Value string `xml:"value"`
|
||||
Unit string `xml:"unit"`
|
||||
UnitExponent string `xml:"unit_mult"`
|
||||
}
|
||||
|
||||
// SwissNationalBankExchangeRateObservationPeriod represents the exchange rate period data from the reserve Swiss National Bank
|
||||
type SwissNationalBankExchangeRateObservationPeriod struct {
|
||||
Period string `xml:"period"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the reserve Swiss National Bank
|
||||
func (e *SwissNationalBankData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e.Channel == nil {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] rss channel does not exist")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(e.Channel.Items) < 1 {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] channel items is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestCurrencyExchangeRateDate := make(map[string]int64)
|
||||
latestExchangeRates := make(map[string]*models.LatestExchangeRate)
|
||||
|
||||
for i := 0; i < len(e.Channel.Items); i++ {
|
||||
item := e.Channel.Items[i]
|
||||
|
||||
if item.Statistics == nil || item.Statistics.ExchangeRate == nil || item.Statistics.ExchangeRate.Observation == nil || item.Statistics.ExchangeRate.ObservationPeriod == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.Statistics.ExchangeRate.BaseCurrency != swissNationalBankBaseCurrency || item.Statistics.ExchangeRate.Observation.Unit != swissNationalBankBaseCurrency {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[item.Statistics.ExchangeRate.TargetCurrency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
date, err := time.Parse(swissNationalBankExchangeRatePeriodDateFormat, item.Statistics.ExchangeRate.ObservationPeriod.Period)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] failed to parse exchange rate period date, period is %s", item.Statistics.ExchangeRate.ObservationPeriod.Period)
|
||||
continue
|
||||
}
|
||||
|
||||
currency := item.Statistics.ExchangeRate.TargetCurrency
|
||||
latestDate, exists := latestCurrencyExchangeRateDate[currency]
|
||||
|
||||
if !exists || date.Unix() > latestDate {
|
||||
finalExchangeRate := item.Statistics.ExchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate != nil {
|
||||
latestCurrencyExchangeRateDate[currency] = date.Unix()
|
||||
latestExchangeRates[currency] = finalExchangeRate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Channel.Items))
|
||||
|
||||
for _, exchangeRate := range latestExchangeRates {
|
||||
exchangeRates = append(exchangeRates, exchangeRate)
|
||||
}
|
||||
|
||||
updateDateTime := e.Channel.PublishDate
|
||||
updateTime, err := time.Parse(swissNationalBankDataUpdateDateFormat, updateDateTime)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: swissNationalBankDataSource,
|
||||
ReferenceUrl: swissNationalBankExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: swissNationalBankBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from the reserve Swiss National Bank
|
||||
func (e *SwissNationalBankExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Observation.Value)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.TargetCurrency, e.Observation.Value)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.TargetCurrency, e.Observation.Value)
|
||||
return nil
|
||||
}
|
||||
|
||||
unitExponent, err := utils.StringToInt(e.Observation.UnitExponent)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit exponent is %s", e.TargetCurrency, e.Observation.UnitExponent)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 1 / rate
|
||||
|
||||
if unitExponent > 1 {
|
||||
finalRate = finalRate / math.Pow10(unitExponent-1)
|
||||
} else if unitExponent < 0 {
|
||||
finalRate = finalRate * math.Pow10(-unitExponent)
|
||||
} else if unitExponent == 0 {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] unit exponent is zero, currency is %s", e.TargetCurrency)
|
||||
return nil
|
||||
}
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.TargetCurrency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the Swiss National Bank exchange rates http requests
|
||||
func (e *SwissNationalBankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", swissNationalBankExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the the reserve Swiss National Bank data source raw response
|
||||
func (e *SwissNationalBankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
swissNationalBankData := &SwissNationalBankData{}
|
||||
err := xmlDecoder.Decode(swissNationalBankData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := swissNationalBankData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const SwissNationalBankMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n" +
|
||||
" <item>\n" +
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:value>0.9378</cb:value>\n" +
|
||||
" <cb:unit>CHF</cb:unit>\n" +
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n" +
|
||||
" </cb:observation>\n" +
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n" +
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n" +
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:period>2024-11-12</cb:period>\n" +
|
||||
" </cb:observationPeriod>\n" +
|
||||
" </cb:exchangeRate>\n" +
|
||||
" </cb:statistics>\n" +
|
||||
" </item>\n" +
|
||||
" <item>\n" +
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:value>0.5727</cb:value>\n" +
|
||||
" <cb:unit>CHF</cb:unit>\n" +
|
||||
" <cb:unit_mult>-2</cb:unit_mult>\n" +
|
||||
" </cb:observation>\n" +
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n" +
|
||||
" <cb:targetCurrency>JPY</cb:targetCurrency>\n" +
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:period>2024-11-12</cb:period>\n" +
|
||||
" </cb:observationPeriod>\n" +
|
||||
" </cb:exchangeRate>\n" +
|
||||
" </cb:statistics>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
func TestSwissNationalBankDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "CHF", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731409250), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "EUR",
|
||||
Rate: "1.0663254425250588",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "174.6114894360049",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_MultipleDateExchanges(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9381</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-11</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "EUR",
|
||||
Rate: "1.0663254425250588",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_EmptyRDFContent(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
"</rss>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_EmptyChannelContent(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_NoItem(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_BaseCurrencyNotEqualPreset(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>EUR</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>CHF</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_UnitCurrencyNotEqualPreset(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>EUR</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>XXX</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value></cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>null</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_InvalidUnit(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>null</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>0</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -13,6 +13,9 @@ var AllLanguages = map[string]*LocaleInfo{
|
||||
"en": {
|
||||
Content: en,
|
||||
},
|
||||
"vi": {
|
||||
Content: vi,
|
||||
},
|
||||
"zh-Hans": {
|
||||
Content: zhHans,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package locales
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
var vi = &LocaleTextItems{
|
||||
DefaultTypes: &DefaultTypes{
|
||||
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_DOT,
|
||||
},
|
||||
DataConverterTextItems: &DataConverterTextItems{
|
||||
Alipay: "Alipay",
|
||||
WeChatWallet: "Ví WeChat",
|
||||
},
|
||||
VerifyEmailTextItems: &VerifyEmailTextItems{
|
||||
Title: "Xác minh Email",
|
||||
SalutationFormat: "Chào %s,",
|
||||
DescriptionAboveBtn: "Vui lòng nhấp vào liên kết bên dưới để xác nhận địa chỉ email của bạn.",
|
||||
VerifyEmail: "Xác minh Email",
|
||||
DescriptionBelowBtnFormat: "Nếu bạn không đăng ký tài khoản %s, vui lòng bỏ qua email này. Nếu bạn không thể nhấp vào liên kết trên, hãy sao chép và dán liên kết vào trình duyệt của bạn. Liên kết xác minh email sẽ hết hạn sau %v phút.",
|
||||
},
|
||||
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
||||
Title: "Đặt lại Mật khẩu",
|
||||
SalutationFormat: "Chào %s,",
|
||||
DescriptionAboveBtn: "Chúng tôi vừa nhận được yêu cầu đặt lại mật khẩu của bạn. Bạn có thể nhấp vào liên kết bên dưới để đặt lại mật khẩu.",
|
||||
ResetPassword: "Đặt lại Mật khẩu",
|
||||
DescriptionBelowBtnFormat: "Nếu bạn không yêu cầu đặt lại mật khẩu, vui lòng bỏ qua email này. Nếu bạn không thể nhấp vào liên kết trên, hãy sao chép và dán liên kết vào trình duyệt của bạn. Liên kết đặt lại mật khẩu sẽ hết hạn sau %v phút.",
|
||||
},
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const settingsCookieName = "ebk_server_settings"
|
||||
|
||||
// ServerSettingsCookie adds server settings to cookies in response
|
||||
func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc {
|
||||
return func(c *core.WebContext) {
|
||||
settingsArr := []string{
|
||||
buildBooleanSetting("r", config.EnableUserRegister),
|
||||
buildBooleanSetting("f", config.EnableUserForgetPassword),
|
||||
buildBooleanSetting("v", config.EnableUserVerifyEmail),
|
||||
buildBooleanSetting("p", config.EnableTransactionPictures),
|
||||
buildBooleanSetting("s", config.EnableScheduledTransaction),
|
||||
buildBooleanSetting("e", config.EnableDataExport),
|
||||
buildBooleanSetting("i", config.EnableDataImport),
|
||||
buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)),
|
||||
}
|
||||
|
||||
if config.EnableMapDataFetchProxy &&
|
||||
(config.MapProvider == settings.OpenStreetMapProvider ||
|
||||
config.MapProvider == settings.OpenStreetMapHumanitarianStyleProvider ||
|
||||
config.MapProvider == settings.OpenTopoMapProvider ||
|
||||
config.MapProvider == settings.OPNVKarteMapProvider ||
|
||||
config.MapProvider == settings.CyclOSMMapProvider ||
|
||||
config.MapProvider == settings.CartoDBMapProvider ||
|
||||
config.MapProvider == settings.TomTomMapProvider ||
|
||||
config.MapProvider == settings.TianDiTuProvider ||
|
||||
config.MapProvider == settings.CustomProvider) {
|
||||
settingsArr = append(settingsArr, buildBooleanSetting("mp", config.EnableMapDataFetchProxy))
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.CustomProvider {
|
||||
settingsArr = append(settingsArr, buildStringSetting("cmzl", fmt.Sprintf("%d-%d-%d", config.CustomMapTileServerMinZoomLevel, config.CustomMapTileServerMaxZoomLevel, config.CustomMapTileServerDefaultZoomLevel)))
|
||||
|
||||
if !config.EnableMapDataFetchProxy {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("cmsu", config.CustomMapTileServerTileLayerUrl))
|
||||
|
||||
if config.CustomMapTileServerAnnotationLayerUrl != "" {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("cmau", config.CustomMapTileServerAnnotationLayerUrl))
|
||||
}
|
||||
} else {
|
||||
if config.CustomMapTileServerAnnotationLayerUrl != "" {
|
||||
settingsArr = append(settingsArr, buildBooleanSetting("cmap", config.EnableMapDataFetchProxy))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TomTomMapProvider && config.TomTomMapAPIKey != "" && !config.EnableMapDataFetchProxy {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("tmak", config.TomTomMapAPIKey))
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TianDiTuProvider && config.TianDiTuAPIKey != "" && !config.EnableMapDataFetchProxy {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("tdak", config.TianDiTuAPIKey))
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.GoogleMapProvider && config.GoogleMapAPIKey != "" {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("gmak", config.GoogleMapAPIKey))
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.BaiduMapProvider && config.BaiduMapAK != "" {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("bmak", config.BaiduMapAK))
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapApplicationKey != "" {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("amak", config.AmapApplicationKey))
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod != "" {
|
||||
settingsArr = append(settingsArr, buildStringSetting("amsv", strings.Replace(config.AmapSecurityVerificationMethod, "_", "", -1)))
|
||||
|
||||
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationExternalProxyMethod {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("amep", config.AmapApiExternalProxyUrl))
|
||||
}
|
||||
|
||||
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationPlainTextMethod {
|
||||
settingsArr = append(settingsArr, buildEncodedStringSetting("amas", config.AmapApplicationSecret))
|
||||
}
|
||||
}
|
||||
|
||||
bundledSettings := strings.Join(settingsArr, "_")
|
||||
c.SetCookie(settingsCookieName, bundledSettings, int(config.TokenExpiredTime), "", "", false, false)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func buildStringSetting(key string, value string) string {
|
||||
return fmt.Sprintf("%s.%s", key, value)
|
||||
}
|
||||
|
||||
func buildEncodedStringSetting(key string, value string) string {
|
||||
urlEncodedValue := url.QueryEscape(value)
|
||||
base64Value := base64.StdEncoding.EncodeToString([]byte(urlEncodedValue))
|
||||
return fmt.Sprintf("%s.%s", key, base64Value)
|
||||
}
|
||||
|
||||
func buildBooleanSetting(key string, value bool) string {
|
||||
if value {
|
||||
return fmt.Sprintf("%s.1", key)
|
||||
} else {
|
||||
return fmt.Sprintf("%s.0", key)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package models
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// LevelOneAccountParentId represents the parent id of level-one account
|
||||
const LevelOneAccountParentId = 0
|
||||
|
||||
@@ -52,6 +54,8 @@ const (
|
||||
ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS AccountType = 2
|
||||
)
|
||||
|
||||
var defaultCreditCardAccountStatementDate = 0
|
||||
|
||||
// Account represents account data stored in database
|
||||
type Account struct {
|
||||
AccountId int64 `xorm:"PK"`
|
||||
@@ -67,12 +71,18 @@ type Account struct {
|
||||
Currency string `xorm:"VARCHAR(3) NOT NULL"`
|
||||
Balance int64 `xorm:"NOT NULL"`
|
||||
Comment string `xorm:"VARCHAR(255) NOT NULL"`
|
||||
Extend *AccountExtend `xorm:"BLOB"`
|
||||
Hidden bool `xorm:"NOT NULL"`
|
||||
CreatedUnixTime int64
|
||||
UpdatedUnixTime int64
|
||||
DeletedUnixTime int64
|
||||
}
|
||||
|
||||
// AccountExtend represents account extend data stored in database
|
||||
type AccountExtend struct {
|
||||
CreditCardStatementDate *int `json:"creditCardStatementDate"`
|
||||
}
|
||||
|
||||
// AccountCreateRequest represents all parameters of account creation request
|
||||
type AccountCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
@@ -82,7 +92,9 @@ type AccountCreateRequest struct {
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
Currency string `json:"currency" binding:"required,len=3,validCurrency"`
|
||||
Balance int64 `json:"balance"`
|
||||
BalanceTime int64 `json:"balanceTime"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
|
||||
SubAccounts []*AccountCreateRequest `json:"subAccounts" binding:"omitempty"`
|
||||
ClientSessionId string `json:"clientSessionId"`
|
||||
}
|
||||
@@ -95,6 +107,7 @@ type AccountModifyRequest struct {
|
||||
Icon int64 `json:"icon,string" binding:"min=1"`
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
|
||||
Hidden bool `json:"hidden"`
|
||||
SubAccounts []*AccountModifyRequest `json:"subAccounts" binding:"omitempty"`
|
||||
}
|
||||
@@ -143,6 +156,7 @@ type AccountInfoResponse struct {
|
||||
Currency string `json:"currency"`
|
||||
Balance int64 `json:"balance"`
|
||||
Comment string `json:"comment"`
|
||||
CreditCardStatementDate *int `json:"creditCardStatementDate,omitempty"`
|
||||
DisplayOrder int32 `json:"displayOrder"`
|
||||
IsAsset bool `json:"isAsset,omitempty"`
|
||||
IsLiability bool `json:"isLiability,omitempty"`
|
||||
@@ -152,6 +166,16 @@ type AccountInfoResponse struct {
|
||||
|
||||
// ToAccountInfoResponse returns a view-object according to database model
|
||||
func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
||||
var creditCardStatementDate *int
|
||||
|
||||
if a.ParentAccountId == LevelOneAccountParentId && a.Category == ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
if a.Extend != nil {
|
||||
creditCardStatementDate = a.Extend.CreditCardStatementDate
|
||||
} else {
|
||||
creditCardStatementDate = &defaultCreditCardAccountStatementDate
|
||||
}
|
||||
}
|
||||
|
||||
return &AccountInfoResponse{
|
||||
Id: a.AccountId,
|
||||
Name: a.Name,
|
||||
@@ -163,6 +187,7 @@ func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
||||
Currency: a.Currency,
|
||||
Balance: a.Balance,
|
||||
Comment: a.Comment,
|
||||
CreditCardStatementDate: creditCardStatementDate,
|
||||
DisplayOrder: a.DisplayOrder,
|
||||
IsAsset: assetAccountCategory[a.Category],
|
||||
IsLiability: liabilityAccountCategory[a.Category],
|
||||
@@ -170,6 +195,16 @@ func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// FromDB fills the fields from the data stored in database
|
||||
func (a *AccountExtend) FromDB(data []byte) error {
|
||||
return json.Unmarshal(data, a)
|
||||
}
|
||||
|
||||
// ToDB returns the actual stored data in database
|
||||
func (a *AccountExtend) ToDB() ([]byte, error) {
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// AccountInfoResponseSlice represents the slice data structure of AccountInfoResponse
|
||||
type AccountInfoResponseSlice []*AccountInfoResponse
|
||||
|
||||
|
||||
@@ -66,6 +66,17 @@ func (s TransactionDbType) ToTransactionType() (TransactionType, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// TransactionTagFilterType represents transaction tag filter type
|
||||
type TransactionTagFilterType byte
|
||||
|
||||
// Transaction tag filter types
|
||||
const (
|
||||
TRANSACTION_TAG_FILTER_HAS_ANY TransactionTagFilterType = 0
|
||||
TRANSACTION_TAG_FILTER_HAS_ALL TransactionTagFilterType = 1
|
||||
TRANSACTION_TAG_FILTER_NOT_HAS_ANY TransactionTagFilterType = 2
|
||||
TRANSACTION_TAG_FILTER_NOT_HAS_ALL TransactionTagFilterType = 3
|
||||
)
|
||||
|
||||
// Transaction represents transaction data stored in database
|
||||
type Transaction struct {
|
||||
TransactionId int64 `xorm:"PK"`
|
||||
@@ -144,6 +155,7 @@ type TransactionCountRequest struct {
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagIds string `form:"tag_ids"`
|
||||
TagFilterType TransactionTagFilterType `form:"tag_filter_type" binding:"min=0,max=3"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"`
|
||||
@@ -156,6 +168,7 @@ type TransactionListByMaxTimeRequest struct {
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagIds string `form:"tag_ids"`
|
||||
TagFilterType TransactionTagFilterType `form:"tag_filter_type" binding:"min=0,max=3"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"`
|
||||
@@ -177,6 +190,7 @@ type TransactionListInMonthByPageRequest struct {
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagIds string `form:"tag_ids"`
|
||||
TagFilterType TransactionTagFilterType `form:"tag_filter_type" binding:"min=0,max=3"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
@@ -189,12 +203,16 @@ type TransactionListInMonthByPageRequest struct {
|
||||
type TransactionStatisticRequest struct {
|
||||
StartTime int64 `form:"start_time" binding:"min=0"`
|
||||
EndTime int64 `form:"end_time" binding:"min=0"`
|
||||
TagIds string `form:"tag_ids"`
|
||||
TagFilterType TransactionTagFilterType `form:"tag_filter_type" binding:"min=0,max=3"`
|
||||
UseTransactionTimezone bool `form:"use_transaction_timezone"`
|
||||
}
|
||||
|
||||
// TransactionStatisticTrendsRequest represents all parameters of transaction statistic trends request
|
||||
type TransactionStatisticTrendsRequest struct {
|
||||
YearMonthRangeRequest
|
||||
TagIds string `form:"tag_ids"`
|
||||
TagFilterType TransactionTagFilterType `form:"tag_filter_type" binding:"min=0,max=3"`
|
||||
UseTransactionTimezone bool `form:"use_transaction_timezone"`
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTransactionTemplateGetTagIds(t *testing.T) {
|
||||
template := &TransactionTemplate{
|
||||
TagIds: "1,2,3",
|
||||
}
|
||||
|
||||
expectedValue := []int64{1, 2, 3}
|
||||
assert.EqualValues(t, expectedValue, template.GetTagIds())
|
||||
}
|
||||
|
||||
func TestTransactionTemplateInfoResponseSliceLess(t *testing.T) {
|
||||
var transactionTemplateRespSlice TransactionTemplateInfoResponseSlice
|
||||
transactionTemplateRespSlice = append(transactionTemplateRespSlice, &TransactionTemplateInfoResponse{
|
||||
|
||||
@@ -5,8 +5,96 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
)
|
||||
|
||||
func TestTransactionAmountsRequestGetTransactionAmountsRequestItems(t *testing.T) {
|
||||
transactionAmountsRequest := &TransactionAmountsRequest{
|
||||
Query: "name1_1234567890_1234567891|name2_1234567900_1234567901",
|
||||
}
|
||||
|
||||
var expectedValue []*TransactionAmountsRequestItem
|
||||
expectedValue = append(expectedValue, &TransactionAmountsRequestItem{
|
||||
Name: "name1",
|
||||
StartTime: 1234567890,
|
||||
EndTime: 1234567891,
|
||||
})
|
||||
expectedValue = append(expectedValue, &TransactionAmountsRequestItem{
|
||||
Name: "name2",
|
||||
StartTime: 1234567900,
|
||||
EndTime: 1234567901,
|
||||
})
|
||||
|
||||
actualValue, err := transactionAmountsRequest.GetTransactionAmountsRequestItems()
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.EqualValues(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestTransactionAmountsRequestGetTransactionAmountsRequestItems_InvalidValue(t *testing.T) {
|
||||
transactionAmountsRequest := &TransactionAmountsRequest{
|
||||
Query: "name1_1234567890",
|
||||
}
|
||||
|
||||
_, err := transactionAmountsRequest.GetTransactionAmountsRequestItems()
|
||||
assert.NotNil(t, err)
|
||||
assert.EqualError(t, err, errs.ErrQueryItemsInvalid.Message)
|
||||
|
||||
transactionAmountsRequest2 := &TransactionAmountsRequest{
|
||||
Query: "name1_123456789f_1234567891",
|
||||
}
|
||||
|
||||
_, err = transactionAmountsRequest2.GetTransactionAmountsRequestItems()
|
||||
assert.NotNil(t, err)
|
||||
|
||||
transactionAmountsRequest3 := &TransactionAmountsRequest{
|
||||
Query: "name1_1234567890_123456789f",
|
||||
}
|
||||
|
||||
_, err = transactionAmountsRequest3.GetTransactionAmountsRequestItems()
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestYearMonthRangeRequestGetNumericYearMonthRange(t *testing.T) {
|
||||
yearMonthRangeRequest := &YearMonthRangeRequest{
|
||||
StartYearMonth: "2023-4",
|
||||
EndYearMonth: "2024-05",
|
||||
}
|
||||
|
||||
startYear, startMonth, endYear, endMonth, err := yearMonthRangeRequest.GetNumericYearMonthRange()
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int32(2023), startYear)
|
||||
assert.Equal(t, int32(4), startMonth)
|
||||
assert.Equal(t, int32(2024), endYear)
|
||||
assert.Equal(t, int32(5), endMonth)
|
||||
}
|
||||
|
||||
func TestYearMonthRangeRequestGetNumericYearMonthRange_InvalidValues(t *testing.T) {
|
||||
yearMonthRangeRequest := &YearMonthRangeRequest{
|
||||
StartYearMonth: "2023/4",
|
||||
EndYearMonth: "2024/05",
|
||||
}
|
||||
|
||||
_, _, _, _, err := yearMonthRangeRequest.GetNumericYearMonthRange()
|
||||
assert.NotNil(t, err)
|
||||
|
||||
yearMonthRangeRequest2 := &YearMonthRangeRequest{
|
||||
StartYearMonth: "2023-April",
|
||||
}
|
||||
|
||||
_, _, _, _, err = yearMonthRangeRequest2.GetNumericYearMonthRange()
|
||||
assert.NotNil(t, err)
|
||||
|
||||
yearMonthRangeRequest3 := &YearMonthRangeRequest{
|
||||
EndYearMonth: "2024-May",
|
||||
}
|
||||
|
||||
_, _, _, _, err = yearMonthRangeRequest3.GetNumericYearMonthRange()
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestTransactionInfoResponseSliceLess(t *testing.T) {
|
||||
var transactionRespSlice TransactionInfoResponseSlice
|
||||
transactionRespSlice = append(transactionRespSlice, &TransactionInfoResponse{
|
||||
|
||||
@@ -104,6 +104,7 @@ type User struct {
|
||||
CurrencyDisplayType core.CurrencyDisplayType `xorm:"TINYINT"`
|
||||
ExpenseAmountColor AmountColorType `xorm:"TINYINT"`
|
||||
IncomeAmountColor AmountColorType `xorm:"TINYINT"`
|
||||
FeatureRestriction core.UserFeatureRestrictions
|
||||
Disabled bool
|
||||
Deleted bool `xorm:"NOT NULL"`
|
||||
EmailVerified bool `xorm:"NOT NULL"`
|
||||
|
||||
@@ -195,7 +195,7 @@ func (s *AccountService) GetMaxSubAccountDisplayOrder(c core.Context, uid int64,
|
||||
}
|
||||
|
||||
// CreateAccounts saves a new account model to database
|
||||
func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Account, childrenAccounts []*models.Account, utcOffset int16) error {
|
||||
func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Account, mainAccountBalanceTime int64, childrenAccounts []*models.Account, childrenAccountBalanceTimes []int64, utcOffset int16) error {
|
||||
if mainAccount.Uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -230,8 +230,6 @@ func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Acco
|
||||
}
|
||||
}
|
||||
|
||||
transactionTime := utils.GetMinTransactionTimeFromUnixTime(now)
|
||||
|
||||
for i := 0; i < len(allAccounts); i++ {
|
||||
allAccounts[i].Deleted = false
|
||||
allAccounts[i].CreatedUnixTime = now
|
||||
@@ -244,6 +242,14 @@ func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Acco
|
||||
return errs.ErrSystemIsBusy
|
||||
}
|
||||
|
||||
transactionTime := utils.GetMinTransactionTimeFromUnixTime(now)
|
||||
|
||||
if i == 0 && mainAccountBalanceTime > 0 {
|
||||
transactionTime = utils.GetMinTransactionTimeFromUnixTime(mainAccountBalanceTime)
|
||||
} else if i > 0 && len(childrenAccountBalanceTimes) > i-1 && childrenAccountBalanceTimes[i-1] > 0 {
|
||||
transactionTime = utils.GetMinTransactionTimeFromUnixTime(childrenAccountBalanceTimes[i-1])
|
||||
}
|
||||
|
||||
newTransaction := &models.Transaction{
|
||||
TransactionId: transactionId,
|
||||
Uid: allAccounts[i].Uid,
|
||||
@@ -276,10 +282,31 @@ func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Acco
|
||||
|
||||
for i := 0; i < len(allInitTransactions); i++ {
|
||||
transaction := allInitTransactions[i]
|
||||
_, err := sess.Insert(transaction)
|
||||
createdRows, err := sess.Insert(transaction)
|
||||
|
||||
if err != nil || createdRows < 1 { // maybe another transaction has same time
|
||||
sameSecondLatestTransaction := &models.Transaction{}
|
||||
minTransactionTime := utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
|
||||
maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
|
||||
|
||||
has, err := sess.Where("uid=? AND transaction_time>=? AND transaction_time<=?", transaction.Uid, minTransactionTime, maxTransactionTime).OrderBy("transaction_time desc").Limit(1).Get(sameSecondLatestTransaction)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
} else if sameSecondLatestTransaction.TransactionTime == maxTransactionTime-1 {
|
||||
return errs.ErrTooMuchTransactionInOneSecond
|
||||
}
|
||||
|
||||
transaction.TransactionTime = sameSecondLatestTransaction.TransactionTime + 1
|
||||
createdRows, err := sess.Insert(transaction)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if createdRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +329,7 @@ func (s *AccountService) ModifyAccounts(c core.Context, uid int64, accounts []*m
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
for i := 0; i < len(accounts); i++ {
|
||||
account := accounts[i]
|
||||
updatedRows, err := sess.ID(account.AccountId).Cols("name", "category", "icon", "color", "comment", "hidden", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(account)
|
||||
updatedRows, err := sess.ID(account.AccountId).Cols("name", "category", "icon", "color", "comment", "extend", "hidden", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(account)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+23
-11
@@ -78,34 +78,46 @@ func (s *TokenService) ParseTokenByCookie(c *core.WebContext, tokenCookieName st
|
||||
return s.parseToken(c, utils.CookieExtractor{tokenCookieName})
|
||||
}
|
||||
|
||||
// CreateTokenViaCli generates a new normal token and saves to database
|
||||
func (s *TokenService) CreateTokenViaCli(c *core.CliContext, user *models.User) (string, *models.TokenRecord, error) {
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_NORMAL, "ezbookkeeping Cli", s.CurrentConfig().TokenExpiredTimeDuration)
|
||||
return token, tokenRecord, err
|
||||
}
|
||||
|
||||
// CreateToken generates a new normal token and saves to database
|
||||
func (s *TokenService) CreateToken(c *core.WebContext, user *models.User) (string, *core.UserTokenClaims, error) {
|
||||
return s.createToken(c, user, core.USER_TOKEN_TYPE_NORMAL, s.getUserAgent(c), s.CurrentConfig().TokenExpiredTimeDuration)
|
||||
token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_NORMAL, s.getUserAgent(c), s.CurrentConfig().TokenExpiredTimeDuration)
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// CreateRequire2FAToken generates a new token requiring user to verify 2fa passcode and saves to database
|
||||
func (s *TokenService) CreateRequire2FAToken(c *core.WebContext, user *models.User) (string, *core.UserTokenClaims, error) {
|
||||
return s.createToken(c, user, core.USER_TOKEN_TYPE_REQUIRE_2FA, s.getUserAgent(c), s.CurrentConfig().TemporaryTokenExpiredTimeDuration)
|
||||
token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_REQUIRE_2FA, s.getUserAgent(c), s.CurrentConfig().TemporaryTokenExpiredTimeDuration)
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// CreateEmailVerifyToken generates a new email verify token and saves to database
|
||||
func (s *TokenService) CreateEmailVerifyToken(c *core.WebContext, user *models.User) (string, *core.UserTokenClaims, error) {
|
||||
return s.createToken(c, user, core.USER_TOKEN_TYPE_EMAIL_VERIFY, s.getUserAgent(c), s.CurrentConfig().EmailVerifyTokenExpiredTimeDuration)
|
||||
token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_EMAIL_VERIFY, s.getUserAgent(c), s.CurrentConfig().EmailVerifyTokenExpiredTimeDuration)
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// CreateEmailVerifyTokenWithoutUserAgent generates a new email verify token and saves to database
|
||||
func (s *TokenService) CreateEmailVerifyTokenWithoutUserAgent(c core.Context, user *models.User) (string, *core.UserTokenClaims, error) {
|
||||
return s.createToken(c, user, core.USER_TOKEN_TYPE_EMAIL_VERIFY, "", s.CurrentConfig().EmailVerifyTokenExpiredTimeDuration)
|
||||
token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_EMAIL_VERIFY, "", s.CurrentConfig().EmailVerifyTokenExpiredTimeDuration)
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// CreatePasswordResetToken generates a new password reset token and saves to database
|
||||
func (s *TokenService) CreatePasswordResetToken(c *core.WebContext, user *models.User) (string, *core.UserTokenClaims, error) {
|
||||
return s.createToken(c, user, core.USER_TOKEN_TYPE_PASSWORD_RESET, s.getUserAgent(c), s.CurrentConfig().PasswordResetTokenExpiredTimeDuration)
|
||||
token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_PASSWORD_RESET, s.getUserAgent(c), s.CurrentConfig().PasswordResetTokenExpiredTimeDuration)
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// CreatePasswordResetTokenWithoutUserAgent generates a new password reset token and saves to database
|
||||
func (s *TokenService) CreatePasswordResetTokenWithoutUserAgent(c core.Context, user *models.User) (string, *core.UserTokenClaims, error) {
|
||||
return s.createToken(c, user, core.USER_TOKEN_TYPE_PASSWORD_RESET, "", s.CurrentConfig().PasswordResetTokenExpiredTimeDuration)
|
||||
token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_PASSWORD_RESET, "", s.CurrentConfig().PasswordResetTokenExpiredTimeDuration)
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// UpdateTokenLastSeen updates the last seen time of specified token
|
||||
@@ -350,7 +362,7 @@ func (s *TokenService) parseToken(c *core.WebContext, extractor request.Extracto
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
func (s *TokenService) createToken(c core.Context, user *models.User, tokenType core.TokenType, userAgent string, expiryDate time.Duration) (string, *core.UserTokenClaims, error) {
|
||||
func (s *TokenService) createToken(c core.Context, user *models.User, tokenType core.TokenType, userAgent string, expiryDate time.Duration) (string, *core.UserTokenClaims, *models.TokenRecord, error) {
|
||||
var err error
|
||||
now := time.Now()
|
||||
|
||||
@@ -365,7 +377,7 @@ func (s *TokenService) createToken(c core.Context, user *models.User, tokenType
|
||||
}
|
||||
|
||||
if tokenRecord.Secret, err = utils.GetRandomString(10); err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
claims := &core.UserTokenClaims{
|
||||
@@ -381,16 +393,16 @@ func (s *TokenService) createToken(c core.Context, user *models.User, tokenType
|
||||
tokenString, err := jwtToken.SignedString([]byte(tokenRecord.Secret))
|
||||
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
err = s.createTokenRecord(c, tokenRecord)
|
||||
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
return tokenString, claims, err
|
||||
return tokenString, claims, tokenRecord, err
|
||||
}
|
||||
|
||||
func (s *TokenService) getTokenRecord(c core.Context, uid int64, userTokenId int64, createUnixTime int64) (*models.TokenRecord, error) {
|
||||
|
||||
@@ -75,11 +75,11 @@ func (s *TransactionService) GetAllTransactions(c core.Context, uid int64, pageC
|
||||
|
||||
// GetAllTransactionsByMaxTime returns all transactions before given time
|
||||
func (s *TransactionService) GetAllTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, count int32, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
return s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", 1, count, false, noDuplicated)
|
||||
return s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, models.TRANSACTION_TAG_FILTER_HAS_ANY, "", "", 1, count, false, noDuplicated)
|
||||
}
|
||||
|
||||
// GetTransactionsByMaxTime returns transactions before given time
|
||||
func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagIds []int64, noTags bool, amountFilter string, keyword string, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagIds []int64, noTags bool, tagFilterType models.TransactionTagFilterType, amountFilter string, keyword string, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -103,14 +103,9 @@ func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64,
|
||||
actualCount++
|
||||
}
|
||||
|
||||
condition, conditionParams := s.getTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagIds, amountFilter, keyword, noDuplicated)
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagIds, amountFilter, keyword, noDuplicated)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
|
||||
if len(tagIds) > 0 {
|
||||
sess.In("transaction_id", s.getTransactionQueryByTagIdsCondition(uid, maxTransactionTime, minTransactionTime, tagIds))
|
||||
} else if noTags {
|
||||
sess.NotIn("transaction_id", s.getTransactionQueryByAllTagIdsCondition(uid, maxTransactionTime, minTransactionTime))
|
||||
}
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagIds, noTags, tagFilterType)
|
||||
|
||||
err = sess.Limit(int(actualCount), int(count*(page-1))).OrderBy("transaction_time desc").Find(&transactions)
|
||||
|
||||
@@ -118,7 +113,7 @@ func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64,
|
||||
}
|
||||
|
||||
// GetTransactionsInMonthByPage returns all transactions in given year and month
|
||||
func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagIds []int64, noTags bool, amountFilter string, keyword string) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagIds []int64, noTags bool, tagFilterType models.TransactionTagFilterType, amountFilter string, keyword string) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -131,14 +126,9 @@ func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid in
|
||||
|
||||
var transactions []*models.Transaction
|
||||
|
||||
condition, conditionParams := s.getTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagIds, amountFilter, keyword, true)
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagIds, amountFilter, keyword, true)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
|
||||
if len(tagIds) > 0 {
|
||||
sess.In("transaction_id", s.getTransactionQueryByTagIdsCondition(uid, maxTransactionTime, minTransactionTime, tagIds))
|
||||
} else if noTags {
|
||||
sess.NotIn("transaction_id", s.getTransactionQueryByAllTagIdsCondition(uid, maxTransactionTime, minTransactionTime))
|
||||
}
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagIds, noTags, tagFilterType)
|
||||
|
||||
err = sess.OrderBy("transaction_time desc").Find(&transactions)
|
||||
|
||||
@@ -181,23 +171,18 @@ func (s *TransactionService) GetTransactionByTransactionId(c core.Context, uid i
|
||||
|
||||
// GetAllTransactionCount returns total count of transactions
|
||||
func (s *TransactionService) GetAllTransactionCount(c core.Context, uid int64) (int64, error) {
|
||||
return s.GetTransactionCount(c, uid, 0, 0, 0, nil, nil, nil, false, "", "")
|
||||
return s.GetTransactionCount(c, uid, 0, 0, 0, nil, nil, nil, false, models.TRANSACTION_TAG_FILTER_HAS_ANY, "", "")
|
||||
}
|
||||
|
||||
// GetTransactionCount returns count of transactions
|
||||
func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagIds []int64, noTags bool, amountFilter string, keyword string) (int64, error) {
|
||||
func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagIds []int64, noTags bool, tagFilterType models.TransactionTagFilterType, amountFilter string, keyword string) (int64, error) {
|
||||
if uid <= 0 {
|
||||
return 0, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
condition, conditionParams := s.getTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagIds, amountFilter, keyword, true)
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagIds, amountFilter, keyword, true)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
|
||||
if len(tagIds) > 0 {
|
||||
sess.In("transaction_id", s.getTransactionQueryByTagIdsCondition(uid, maxTransactionTime, minTransactionTime, tagIds))
|
||||
} else if noTags {
|
||||
sess.NotIn("transaction_id", s.getTransactionQueryByAllTagIdsCondition(uid, maxTransactionTime, minTransactionTime))
|
||||
}
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagIds, noTags, tagFilterType)
|
||||
|
||||
return sess.Count(&models.Transaction{})
|
||||
}
|
||||
@@ -1303,7 +1288,7 @@ func (s *TransactionService) GetAccountsTotalIncomeAndExpense(c core.Context, ui
|
||||
}
|
||||
|
||||
// GetAccountsAndCategoriesTotalIncomeAndExpense returns the every accounts and categories total income and expense amount by specific date range
|
||||
func (s *TransactionService) GetAccountsAndCategoriesTotalIncomeAndExpense(c core.Context, uid int64, startUnixTime int64, endUnixTime int64, utcOffset int16, useTransactionTimezone bool) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetAccountsAndCategoriesTotalIncomeAndExpense(c core.Context, uid int64, startUnixTime int64, endUnixTime int64, tagIds []int64, noTags bool, tagFilterType models.TransactionTagFilterType, utcOffset int16, useTransactionTimezone bool) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -1351,7 +1336,10 @@ func (s *TransactionService) GetAccountsAndCategoriesTotalIncomeAndExpense(c cor
|
||||
finalConditionParams = append(finalConditionParams, maxTransactionTime)
|
||||
}
|
||||
|
||||
err := s.UserDataDB(uid).NewSession(c).Select("category_id, account_id, transaction_time, timezone_utc_offset, amount").Where(finalCondition, finalConditionParams...).Limit(pageCountForLoadTransactionAmounts, 0).OrderBy("transaction_time desc").Find(&transactions)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Select("category_id, account_id, transaction_time, timezone_utc_offset, amount").Where(finalCondition, finalConditionParams...)
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagIds, noTags, tagFilterType)
|
||||
|
||||
err := sess.Limit(pageCountForLoadTransactionAmounts, 0).OrderBy("transaction_time desc").Find(&transactions)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1409,7 +1397,7 @@ func (s *TransactionService) GetAccountsAndCategoriesTotalIncomeAndExpense(c cor
|
||||
}
|
||||
|
||||
// GetAccountsAndCategoriesMonthlyIncomeAndExpense returns the every accounts monthly income and expense amount by specific date range
|
||||
func (s *TransactionService) GetAccountsAndCategoriesMonthlyIncomeAndExpense(c core.Context, uid int64, startYear int32, startMonth int32, endYear int32, endMonth int32, utcOffset int16, useTransactionTimezone bool) (map[int32][]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetAccountsAndCategoriesMonthlyIncomeAndExpense(c core.Context, uid int64, startYear int32, startMonth int32, endYear int32, endMonth int32, tagIds []int64, noTags bool, tagFilterType models.TransactionTagFilterType, utcOffset int16, useTransactionTimezone bool) (map[int32][]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -1462,7 +1450,10 @@ func (s *TransactionService) GetAccountsAndCategoriesMonthlyIncomeAndExpense(c c
|
||||
finalConditionParams = append(finalConditionParams, maxTransactionTime)
|
||||
}
|
||||
|
||||
err := s.UserDataDB(uid).NewSession(c).Select("category_id, account_id, transaction_time, timezone_utc_offset, amount").Where(finalCondition, finalConditionParams...).Limit(pageCountForLoadTransactionAmounts, 0).OrderBy("transaction_time desc").Find(&transactions)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Select("category_id, account_id, transaction_time, timezone_utc_offset, amount").Where(finalCondition, finalConditionParams...)
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagIds, noTags, tagFilterType)
|
||||
|
||||
err := sess.Limit(pageCountForLoadTransactionAmounts, 0).OrderBy("transaction_time desc").Find(&transactions)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1753,7 +1744,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TransactionService) getTransactionQueryCondition(uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagIds []int64, amountFilter string, keyword string, noDuplicated bool) (string, []any) {
|
||||
func (s *TransactionService) buildTransactionQueryCondition(uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagIds []int64, amountFilter string, keyword string, noDuplicated bool) (string, []any) {
|
||||
condition := "uid=? AND deleted=?"
|
||||
conditionParams := make([]any, 0, 16)
|
||||
conditionParams = append(conditionParams, uid)
|
||||
@@ -1909,38 +1900,41 @@ func (s *TransactionService) getTransactionQueryCondition(uid int64, maxTransact
|
||||
return condition, conditionParams
|
||||
}
|
||||
|
||||
func (s *TransactionService) getTransactionQueryByTagIdsCondition(uid int64, maxTransactionTime int64, minTransactionTime int64, tagIds []int64) *builder.Builder {
|
||||
if len(tagIds) > 0 {
|
||||
condition := builder.And(builder.Eq{"uid": uid}, builder.Eq{"deleted": false})
|
||||
func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Session, uid int64, maxTransactionTime int64, minTransactionTime int64, tagIds []int64, noTags bool, tagFilterType models.TransactionTagFilterType) *xorm.Session {
|
||||
subQueryCondition := builder.And(builder.Eq{"uid": uid}, builder.Eq{"deleted": false})
|
||||
|
||||
if maxTransactionTime > 0 {
|
||||
condition = condition.And(builder.Lte{"transaction_time": maxTransactionTime})
|
||||
subQueryCondition = subQueryCondition.And(builder.Lte{"transaction_time": maxTransactionTime})
|
||||
}
|
||||
|
||||
if minTransactionTime > 0 {
|
||||
condition = condition.And(builder.Gte{"transaction_time": minTransactionTime})
|
||||
subQueryCondition = subQueryCondition.And(builder.Gte{"transaction_time": minTransactionTime})
|
||||
}
|
||||
|
||||
condition = condition.And(builder.In("tag_id", tagIds))
|
||||
|
||||
return builder.Select("transaction_id").From("transaction_tag_index").Where(condition)
|
||||
if noTags {
|
||||
subQuery := builder.Select("transaction_id").From("transaction_tag_index").Where(subQueryCondition)
|
||||
sess.NotIn("transaction_id", subQuery)
|
||||
return sess
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TransactionService) getTransactionQueryByAllTagIdsCondition(uid int64, maxTransactionTime int64, minTransactionTime int64) *builder.Builder {
|
||||
condition := builder.And(builder.Eq{"uid": uid}, builder.Eq{"deleted": false})
|
||||
|
||||
if maxTransactionTime > 0 {
|
||||
condition = condition.And(builder.Lte{"transaction_time": maxTransactionTime})
|
||||
if len(tagIds) < 1 {
|
||||
return sess
|
||||
}
|
||||
|
||||
if minTransactionTime > 0 {
|
||||
condition = condition.And(builder.Gte{"transaction_time": minTransactionTime})
|
||||
subQueryCondition = subQueryCondition.And(builder.In("tag_id", tagIds))
|
||||
subQuery := builder.Select("transaction_id").From("transaction_tag_index").Where(subQueryCondition)
|
||||
|
||||
if tagFilterType == models.TRANSACTION_TAG_FILTER_HAS_ALL || tagFilterType == models.TRANSACTION_TAG_FILTER_NOT_HAS_ALL {
|
||||
subQuery = subQuery.GroupBy("transaction_id").Having(fmt.Sprintf("COUNT(DISTINCT tag_id) >= %d", len(tagIds)))
|
||||
}
|
||||
|
||||
return builder.Select("transaction_id").From("transaction_tag_index").Where(condition)
|
||||
if tagFilterType == models.TRANSACTION_TAG_FILTER_HAS_ANY || tagFilterType == models.TRANSACTION_TAG_FILTER_HAS_ALL {
|
||||
sess.In("transaction_id", subQuery)
|
||||
} else if tagFilterType == models.TRANSACTION_TAG_FILTER_NOT_HAS_ANY || tagFilterType == models.TRANSACTION_TAG_FILTER_NOT_HAS_ALL {
|
||||
sess.NotIn("transaction_id", subQuery)
|
||||
}
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *TransactionService) isAccountIdValid(transaction *models.Transaction) error {
|
||||
|
||||
@@ -471,6 +471,75 @@ func (s *UserService) DisableUser(c core.Context, username string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserFeatureRestriction sets user user feature restrictions
|
||||
func (s *UserService) UpdateUserFeatureRestriction(c core.Context, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
updateModel := &models.User{
|
||||
FeatureRestriction: featureRestriction,
|
||||
UpdatedUnixTime: now,
|
||||
}
|
||||
|
||||
updatedRows, err := s.UserDB().NewSession(c).Cols("feature_restriction", "updated_unix_time").Where("username=? AND deleted=?", username, false).Update(updateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddUserFeatureRestriction adds user user feature restrictions
|
||||
func (s *UserService) AddUserFeatureRestriction(c core.Context, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
updateModel := &models.User{
|
||||
FeatureRestriction: featureRestriction,
|
||||
UpdatedUnixTime: now,
|
||||
}
|
||||
|
||||
updatedRows, err := s.UserDB().NewSession(c).SetExpr("feature_restriction", fmt.Sprintf("feature_restriction|(%d)", updateModel.FeatureRestriction)).Cols("updated_unix_time").Where("username=? AND deleted=?", username, false).Update(updateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserFeatureRestriction removes user user feature restrictions
|
||||
func (s *UserService) RemoveUserFeatureRestriction(c core.Context, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
updateModel := &models.User{
|
||||
FeatureRestriction: ^featureRestriction,
|
||||
UpdatedUnixTime: now,
|
||||
}
|
||||
|
||||
updatedRows, err := s.UserDB().NewSession(c).SetExpr("feature_restriction", fmt.Sprintf("feature_restriction&(%d)", updateModel.FeatureRestriction)).Cols("updated_unix_time").Where("username=? AND deleted=?", username, false).Update(updateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserEmailVerified sets user email address verified
|
||||
func (s *UserService) SetUserEmailVerified(c core.Context, username string) error {
|
||||
if username == "" {
|
||||
|
||||
+80
-14
@@ -100,11 +100,21 @@ const (
|
||||
|
||||
// Exchange rates data source types
|
||||
const (
|
||||
EuroCentralBankDataSource string = "euro_central_bank"
|
||||
BankOfCanadaDataSource string = "bank_of_canada"
|
||||
ReserveBankOfAustraliaDataSource string = "reserve_bank_of_australia"
|
||||
BankOfCanadaDataSource string = "bank_of_canada"
|
||||
CzechNationalBankDataSource string = "czech_national_bank"
|
||||
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"
|
||||
CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
|
||||
NorgesBankDataSource string = "norges_bank"
|
||||
NationalBankOfPolandDataSource string = "national_bank_of_poland"
|
||||
NationalBankOfRomaniaDataSource string = "national_bank_of_romania"
|
||||
BankOfRussiaDataSource string = "bank_of_russia"
|
||||
SwissNationalBankDataSource string = "swiss_national_bank"
|
||||
CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
|
||||
InternationalMonetaryFundDataSource string = "international_monetary_fund"
|
||||
)
|
||||
|
||||
@@ -181,6 +191,13 @@ type MinIOConfig struct {
|
||||
RootPath string
|
||||
}
|
||||
|
||||
// TipConfig represents a tip setting config
|
||||
type TipConfig struct {
|
||||
Enabled bool
|
||||
DefaultContent string
|
||||
MultiLanguageContent map[string]string
|
||||
}
|
||||
|
||||
// NotificationConfig represents a notification setting config
|
||||
type NotificationConfig struct {
|
||||
Enabled bool
|
||||
@@ -282,12 +299,16 @@ type Config struct {
|
||||
EnableScheduledTransaction bool
|
||||
AvatarProvider core.UserAvatarProviderType
|
||||
MaxAvatarFileSize uint32
|
||||
DefaultFeatureRestrictions core.UserFeatureRestrictions
|
||||
|
||||
// Data
|
||||
EnableDataExport bool
|
||||
EnableDataImport bool
|
||||
MaxImportFileSize uint32
|
||||
|
||||
// Tip
|
||||
LoginPageTips TipConfig
|
||||
|
||||
// Notification
|
||||
AfterRegisterNotification NotificationConfig
|
||||
AfterLoginNotification NotificationConfig
|
||||
@@ -314,6 +335,7 @@ type Config struct {
|
||||
// Exchange Rates
|
||||
ExchangeRatesDataSource string
|
||||
ExchangeRatesRequestTimeout uint32
|
||||
ExchangeRatesRequestTimeoutExceedDefaultValue bool
|
||||
ExchangeRatesProxy string
|
||||
ExchangeRatesSkipTLSVerify bool
|
||||
}
|
||||
@@ -407,6 +429,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = loadTipConfiguration(config, cfgFile, "tip")
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = loadNotificationConfiguration(config, cfgFile, "notification")
|
||||
|
||||
if err != nil {
|
||||
@@ -766,6 +794,7 @@ func loadUserConfiguration(config *Config, configFile *ini.File, sectionName str
|
||||
}
|
||||
|
||||
config.MaxAvatarFileSize = getConfigItemUint32Value(configFile, sectionName, "max_user_avatar_size", defaultUserAvatarFileMaxSize)
|
||||
config.DefaultFeatureRestrictions = core.ParseUserFeatureRestrictions(getConfigItemStringValue(configFile, sectionName, "default_feature_restrictions", ""))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -778,6 +807,12 @@ func loadDataConfiguration(config *Config, configFile *ini.File, sectionName str
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTipConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
config.LoginPageTips = getTipConfiguration(configFile, sectionName, "enable_tips_in_login_page", "login_page_tips_content")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadNotificationConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
config.AfterRegisterNotification = getNotificationConfiguration(configFile, sectionName, "enable_notification_after_register", "after_register_notification_content")
|
||||
config.AfterLoginNotification = getNotificationConfiguration(configFile, sectionName, "enable_notification_after_login", "after_login_notification_content")
|
||||
@@ -853,24 +888,34 @@ func loadMapConfiguration(config *Config, configFile *ini.File, sectionName stri
|
||||
func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
dataSource := getConfigItemStringValue(configFile, sectionName, "data_source")
|
||||
|
||||
if dataSource == EuroCentralBankDataSource {
|
||||
config.ExchangeRatesDataSource = EuroCentralBankDataSource
|
||||
} else if dataSource == BankOfCanadaDataSource {
|
||||
config.ExchangeRatesDataSource = BankOfCanadaDataSource
|
||||
} else if dataSource == ReserveBankOfAustraliaDataSource {
|
||||
config.ExchangeRatesDataSource = ReserveBankOfAustraliaDataSource
|
||||
} else if dataSource == CzechNationalBankDataSource {
|
||||
config.ExchangeRatesDataSource = CzechNationalBankDataSource
|
||||
} else if dataSource == NationalBankOfPolandDataSource {
|
||||
config.ExchangeRatesDataSource = NationalBankOfPolandDataSource
|
||||
} else if dataSource == InternationalMonetaryFundDataSource {
|
||||
config.ExchangeRatesDataSource = InternationalMonetaryFundDataSource
|
||||
if dataSource == ReserveBankOfAustraliaDataSource ||
|
||||
dataSource == BankOfCanadaDataSource ||
|
||||
dataSource == CzechNationalBankDataSource ||
|
||||
dataSource == DanmarksNationalbankDataSource ||
|
||||
dataSource == EuroCentralBankDataSource ||
|
||||
dataSource == NationalBankOfGeorgiaDataSource ||
|
||||
dataSource == CentralBankOfHungaryDataSource ||
|
||||
dataSource == BankOfIsraelDataSource ||
|
||||
dataSource == CentralBankOfMyanmarDataSource ||
|
||||
dataSource == NorgesBankDataSource ||
|
||||
dataSource == NationalBankOfPolandDataSource ||
|
||||
dataSource == NationalBankOfRomaniaDataSource ||
|
||||
dataSource == BankOfRussiaDataSource ||
|
||||
dataSource == SwissNationalBankDataSource ||
|
||||
dataSource == CentralBankOfUzbekistanDataSource ||
|
||||
dataSource == InternationalMonetaryFundDataSource {
|
||||
config.ExchangeRatesDataSource = dataSource
|
||||
} else {
|
||||
return errs.ErrInvalidExchangeRatesDataSource
|
||||
}
|
||||
|
||||
config.ExchangeRatesProxy = getConfigItemStringValue(configFile, sectionName, "proxy", "system")
|
||||
config.ExchangeRatesRequestTimeout = getConfigItemUint32Value(configFile, sectionName, "request_timeout", defaultExchangeRatesDataRequestTimeout)
|
||||
|
||||
if config.ExchangeRatesRequestTimeout > defaultExchangeRatesDataRequestTimeout {
|
||||
config.ExchangeRatesRequestTimeoutExceedDefaultValue = true
|
||||
}
|
||||
|
||||
config.ExchangeRatesSkipTLSVerify = getConfigItemBoolValue(configFile, sectionName, "skip_tls_verify", false)
|
||||
|
||||
return nil
|
||||
@@ -906,6 +951,27 @@ func getFinalPath(workingPath, p string) (string, error) {
|
||||
return p, err
|
||||
}
|
||||
|
||||
func getTipConfiguration(configFile *ini.File, sectionName string, enableKey string, contentKey string) TipConfig {
|
||||
config := TipConfig{
|
||||
Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false),
|
||||
DefaultContent: getConfigItemStringValue(configFile, sectionName, contentKey, ""),
|
||||
MultiLanguageContent: make(map[string]string),
|
||||
}
|
||||
|
||||
for languageTag := range locales.AllLanguages {
|
||||
multiLanguageContentKey := strings.ToLower(languageTag)
|
||||
multiLanguageContentKey = strings.Replace(multiLanguageContentKey, "-", "_", -1)
|
||||
multiLanguageContentKey = contentKey + "_" + multiLanguageContentKey
|
||||
content := getConfigItemStringValue(configFile, sectionName, multiLanguageContentKey, "")
|
||||
|
||||
if content != "" {
|
||||
config.MultiLanguageContent[languageTag] = content
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func getNotificationConfiguration(configFile *ini.File, sectionName string, enableKey string, contentKey string) NotificationConfig {
|
||||
config := NotificationConfig{
|
||||
Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<img style="display: none;" :src="devCookiePath" v-if="!isProduction" />
|
||||
<v-app>
|
||||
<router-view />
|
||||
</v-app>
|
||||
@@ -35,8 +34,6 @@ import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui.js';
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isProduction: isProduction(),
|
||||
devCookiePath: isProduction() ? '' : '/dev/cookies',
|
||||
showNotification: false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<img style="display: none;" :src="devCookiePath" v-if="!isProduction" />
|
||||
<f7-app v-bind="f7params">
|
||||
<f7-view id="main-view" class="safe-areas" main url="/"></f7-view>
|
||||
</f7-app>
|
||||
@@ -35,8 +34,6 @@ export default {
|
||||
}
|
||||
|
||||
return {
|
||||
isProduction: isProduction(),
|
||||
devCookiePath: isProduction() ? '' : '/dev/cookies',
|
||||
notification: null,
|
||||
f7params: {
|
||||
name: 'ezBookkeeping',
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useUserStore } from '@/stores/user.js';
|
||||
|
||||
import transactionConstants from '@/consts/transaction.js';
|
||||
import { removeAll } from '@/lib/common.js';
|
||||
import logger from '@/lib/logger.js';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
@@ -43,6 +44,7 @@ export default {
|
||||
'color',
|
||||
'density',
|
||||
'currency',
|
||||
'showCurrency',
|
||||
'label',
|
||||
'placeholder',
|
||||
'persistentPlaceholder',
|
||||
@@ -75,7 +77,8 @@ export default {
|
||||
}
|
||||
|
||||
return (val >= transactionConstants.minAmountNumber && val <= transactionConstants.maxAmountNumber) || self.$t('Amount value exceeds limitation');
|
||||
} catch (e) {
|
||||
} catch (ex) {
|
||||
logger.warn('cannot parse amount in amount input, original value is ' + v, ex);
|
||||
return self.$t('Amount value is not number');
|
||||
}
|
||||
}
|
||||
@@ -98,7 +101,7 @@ export default {
|
||||
return finalClass;
|
||||
},
|
||||
prependText() {
|
||||
if (!this.currency) {
|
||||
if (!this.currency || !this.showCurrency) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -111,7 +114,7 @@ export default {
|
||||
return texts.prependText;
|
||||
},
|
||||
appendText() {
|
||||
if (!this.currency) {
|
||||
if (!this.currency || !this.showCurrency) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -125,6 +128,13 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'currency': function () {
|
||||
const newStringValue = this.getFormattedValue(this.userStore, this.modelValue);
|
||||
|
||||
if (!(newStringValue === '0' && this.currentValue === '')) {
|
||||
this.currentValue = newStringValue;
|
||||
}
|
||||
},
|
||||
'modelValue': function (newValue) {
|
||||
const numericCurrentValue = this.$locale.parseAmount(this.userStore, this.currentValue);
|
||||
|
||||
@@ -249,8 +259,8 @@ export default {
|
||||
this.currentValue = finalValue;
|
||||
e.preventDefault();
|
||||
}
|
||||
} catch (e) {
|
||||
e.target.value = '0';
|
||||
} catch (ex) {
|
||||
ex.target.value = '0';
|
||||
}
|
||||
},
|
||||
onPaste(e) {
|
||||
@@ -298,7 +308,7 @@ export default {
|
||||
getFormattedValue(userStore, value) {
|
||||
if (!Number.isNaN(value) && Number.isFinite(value)) {
|
||||
const digitGroupingSymbol = this.$locale.getCurrentDigitGroupingSymbol(userStore);
|
||||
return removeAll(this.$locale.formatAmount(userStore, value), digitGroupingSymbol);
|
||||
return removeAll(this.$locale.formatAmount(userStore, value, this.currency), digitGroupingSymbol);
|
||||
}
|
||||
|
||||
return '0';
|
||||
|
||||
@@ -194,6 +194,10 @@ export default {
|
||||
max-height: inherit !important;
|
||||
}
|
||||
|
||||
.schedule-frequency-select-menu > .v-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.schedule-frequency-select-menu .schedule-frequency-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -11,14 +11,20 @@ import { useSettingsStore } from '@/stores/setting.js';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
|
||||
import colorConstants from '@/consts/color.js';
|
||||
import datetimeConstants from '@/consts/datetime.js';
|
||||
import statisticsConstants from '@/consts/statistics.js';
|
||||
import { isNumber } from '@/lib/common.js';
|
||||
import {
|
||||
getYearMonthStringFromObject,
|
||||
getAllYearMonthUnixTimesBetweenStartYearMonthAndEndYearMonth
|
||||
isArray,
|
||||
isNumber
|
||||
} from '@/lib/common.js';
|
||||
import {
|
||||
getYearMonthFirstUnixTime,
|
||||
getYearMonthLastUnixTime,
|
||||
getDateTypeByDateRange
|
||||
} from '@/lib/datetime.js';
|
||||
import {
|
||||
sortStatisticsItems
|
||||
sortStatisticsItems,
|
||||
getAllDateRanges
|
||||
} from '@/lib/statistics.js';
|
||||
|
||||
export default {
|
||||
@@ -29,6 +35,7 @@ export default {
|
||||
'startYearMonth',
|
||||
'endYearMonth',
|
||||
'sortingType',
|
||||
'dateAggregationType',
|
||||
'idField',
|
||||
'nameField',
|
||||
'valueField',
|
||||
@@ -67,49 +74,35 @@ export default {
|
||||
id = this.getItemName(item[this.nameField]);
|
||||
}
|
||||
|
||||
map[id] = item;
|
||||
map[id] = {
|
||||
[this.idField || 'id']: id,
|
||||
[this.nameField || 'name']: item[this.nameField],
|
||||
[this.hiddenField || 'hidden']: item[this.hiddenField],
|
||||
[this.displayOrdersField || 'displayOrders']: item[this.displayOrdersField]
|
||||
};
|
||||
}
|
||||
|
||||
return map;
|
||||
},
|
||||
allYearMonthTimes: function () {
|
||||
if (this.startYearMonth && this.endYearMonth) {
|
||||
return getAllYearMonthUnixTimesBetweenStartYearMonthAndEndYearMonth(this.startYearMonth, this.endYearMonth);
|
||||
} else if (this.items && this.items.length) {
|
||||
let minYear = Number.MAX_SAFE_INTEGER, minMonth = Number.MAX_SAFE_INTEGER, maxYear = 0, maxMonth = 0;
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const item = this.items[i];
|
||||
|
||||
for (let j = 0; j < item.items.length; j++) {
|
||||
const dataItem = item.items[j];
|
||||
|
||||
if (dataItem.year < minYear || (dataItem.year === minYear && dataItem.month < minMonth)) {
|
||||
minYear = dataItem.year;
|
||||
minMonth = dataItem.month;
|
||||
}
|
||||
|
||||
if (dataItem.year > maxYear || (dataItem.year === maxYear && dataItem.month > maxMonth)) {
|
||||
maxYear = dataItem.year;
|
||||
maxMonth = dataItem.month;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getAllYearMonthUnixTimesBetweenStartYearMonthAndEndYearMonth(`${minYear}-${minMonth}`, `${maxYear}-${maxMonth}`);
|
||||
}
|
||||
|
||||
return [];
|
||||
allDateRanges: function () {
|
||||
return getAllDateRanges(this.items, this.startYearMonth, this.endYearMonth, this.dateAggregationType);
|
||||
},
|
||||
allDisplayMonths: function () {
|
||||
const allDisplayMonths = [];
|
||||
allDisplayDateRanges: function () {
|
||||
const allDisplayDateRanges = [];
|
||||
|
||||
for (let i = 0; i < this.allYearMonthTimes.length; i++) {
|
||||
const yearMonthTime = this.allYearMonthTimes[i];
|
||||
allDisplayMonths.push(this.$locale.formatUnixTimeToShortYearMonth(this.userStore, yearMonthTime.minUnixTime));
|
||||
for (let i = 0; i < this.allDateRanges.length; i++) {
|
||||
const dateRange = this.allDateRanges[i];
|
||||
|
||||
if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) {
|
||||
allDisplayDateRanges.push(this.$locale.formatUnixTimeToShortYear(this.userStore, dateRange.minUnixTime));
|
||||
} else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) {
|
||||
allDisplayDateRanges.push(this.$locale.formatYearQuarter(dateRange.year, dateRange.quarter));
|
||||
} else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) {
|
||||
allDisplayDateRanges.push(this.$locale.formatUnixTimeToShortYearMonth(this.userStore, dateRange.minUnixTime));
|
||||
}
|
||||
}
|
||||
|
||||
return allDisplayMonths;
|
||||
return allDisplayDateRanges;
|
||||
},
|
||||
allSeries: function () {
|
||||
const allSeries = [];
|
||||
@@ -122,20 +115,49 @@ export default {
|
||||
}
|
||||
|
||||
const allAmounts = [];
|
||||
const yearMonthDataMap = {};
|
||||
const dateRangeAmountMap = {};
|
||||
|
||||
for (let j = 0; j < item.items.length; j++) {
|
||||
const dataItem = item.items[j];
|
||||
yearMonthDataMap[`${dataItem.year}-${dataItem.month}`] = dataItem;
|
||||
let dateRangeKey = '';
|
||||
|
||||
if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) {
|
||||
dateRangeKey = dataItem.year;
|
||||
} else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) {
|
||||
dateRangeKey = `${dataItem.year}-${Math.floor((dataItem.month - 1) / 3) + 1}`;
|
||||
} else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) {
|
||||
dateRangeKey = `${dataItem.year}-${dataItem.month}`;
|
||||
}
|
||||
|
||||
for (let j = 0; j < this.allYearMonthTimes.length; j++) {
|
||||
const yearMonth = getYearMonthStringFromObject(this.allYearMonthTimes[j]);
|
||||
const dataItem = yearMonthDataMap[yearMonth];
|
||||
let amount = 0;
|
||||
const dataItems = dateRangeAmountMap[dateRangeKey] || [];
|
||||
dataItems.push(dataItem);
|
||||
|
||||
if (dataItem && isNumber(dataItem[this.valueField])) {
|
||||
amount = dataItem[this.valueField];
|
||||
dateRangeAmountMap[dateRangeKey] = dataItems;
|
||||
}
|
||||
|
||||
for (let j = 0; j < this.allDateRanges.length; j++) {
|
||||
const dateRange = this.allDateRanges[j];
|
||||
let dateRangeKey = '';
|
||||
|
||||
if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) {
|
||||
dateRangeKey = dateRange.year;
|
||||
} else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) {
|
||||
dateRangeKey = `${dateRange.year}-${dateRange.quarter}`;
|
||||
} else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) {
|
||||
dateRangeKey = `${dateRange.year}-${dateRange.month + 1}`;
|
||||
}
|
||||
|
||||
let amount = 0;
|
||||
const dataItems = dateRangeAmountMap[dateRangeKey];
|
||||
|
||||
if (isArray(dataItems)) {
|
||||
for (let i = 0; i < dataItems.length; i++) {
|
||||
const dataItem = dataItems[i];
|
||||
|
||||
if (isNumber(dataItem[this.valueField])) {
|
||||
amount += dataItem[this.valueField];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allAmounts.push(amount);
|
||||
@@ -240,14 +262,14 @@ export default {
|
||||
displayItems.push({
|
||||
name: name,
|
||||
color: color,
|
||||
displayOrders:displayOrders,
|
||||
displayOrders: displayOrders,
|
||||
totalAmount: amount
|
||||
});
|
||||
|
||||
totalAmount += amount;
|
||||
}
|
||||
|
||||
sortStatisticsItems(displayItems, self.sortingType)
|
||||
sortStatisticsItems(displayItems, self.sortingType);
|
||||
|
||||
for (let i = 0; i < displayItems.length; i++) {
|
||||
const item = displayItems[i];
|
||||
@@ -293,7 +315,7 @@ export default {
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: self.allDisplayMonths
|
||||
data: self.allDisplayDateRanges
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
@@ -332,11 +354,36 @@ export default {
|
||||
|
||||
const id = e.seriesId;
|
||||
const item = this.itemsMap[id];
|
||||
const yearMonthTime = this.allYearMonthTimes[e.dataIndex];
|
||||
const itemId = this.idField ? item[this.idField] : '';
|
||||
const dateRange = this.allDateRanges[e.dataIndex];
|
||||
let minUnixTime = dateRange.minUnixTime;
|
||||
let maxUnixTime = dateRange.maxUnixTime;
|
||||
|
||||
if (this.startYearMonth) {
|
||||
const startMinUnixTime = getYearMonthFirstUnixTime(this.startYearMonth);
|
||||
|
||||
if (startMinUnixTime > minUnixTime) {
|
||||
minUnixTime = startMinUnixTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.endYearMonth) {
|
||||
const endMaxUnixTime = getYearMonthLastUnixTime(this.endYearMonth);
|
||||
|
||||
if (endMaxUnixTime < maxUnixTime) {
|
||||
maxUnixTime = endMaxUnixTime;
|
||||
}
|
||||
}
|
||||
|
||||
const dateRangeType = getDateTypeByDateRange(minUnixTime, maxUnixTime, this.userStore.currentUserFirstDayOfWeek, datetimeConstants.allDateRangeScenes.Normal);
|
||||
|
||||
this.$emit('click', {
|
||||
yearMonth: getYearMonthStringFromObject(yearMonthTime),
|
||||
item: item
|
||||
itemId: itemId,
|
||||
dateRange: {
|
||||
minTime: minUnixTime,
|
||||
maxTime: maxUnixTime,
|
||||
type: dateRangeType
|
||||
}
|
||||
});
|
||||
},
|
||||
getColor: function (color) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<f7-sheet swipe-to-close swipe-handler=".swipe-handler" class="month-range-selection-sheet" style="height:auto"
|
||||
:opened="show" @sheet:open="onSheetOpen" @sheet:closed="onSheetClosed">
|
||||
<div class="swipe-handler" style="z-index: 10"></div>
|
||||
<f7-page-content>
|
||||
<div class="display-flex padding justify-content-space-between align-items-center">
|
||||
<div class="ebk-sheet-title" v-if="title"><b>{{ title }}</b></div>
|
||||
</div>
|
||||
<div class="padding-horizontal padding-bottom">
|
||||
<p class="no-margin-top" v-if="hint">{{ hint }}</p>
|
||||
<p class="no-margin-top margin-bottom" v-if="beginDateTime && endDateTime">
|
||||
<span>{{ beginDateTime }}</span>
|
||||
<span> - </span>
|
||||
<span>{{ endDateTime }}</span>
|
||||
</p>
|
||||
<slot></slot>
|
||||
<vue-date-picker inline month-picker auto-apply
|
||||
month-name-format="long"
|
||||
class="justify-content-center margin-bottom"
|
||||
:clearable="false"
|
||||
:dark="isDarkMode"
|
||||
:year-range="yearRange"
|
||||
:year-first="isYearFirst"
|
||||
:range="{ partialRange: false }"
|
||||
v-model="dateRange">
|
||||
<template #month="{ text }">
|
||||
{{ getMonthShortName(text) }}
|
||||
</template>
|
||||
<template #month-overlay-value="{ text }">
|
||||
{{ getMonthShortName(text) }}
|
||||
</template>
|
||||
</vue-date-picker>
|
||||
<f7-button large fill
|
||||
:class="{ 'disabled': !dateRange[0] || !dateRange[1] }"
|
||||
:text="$t('Continue')"
|
||||
@click="confirm">
|
||||
</f7-button>
|
||||
<div class="margin-top text-align-center">
|
||||
<f7-link @click="cancel" :text="$t('Cancel')"></f7-link>
|
||||
</div>
|
||||
</div>
|
||||
</f7-page-content>
|
||||
</f7-sheet>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
|
||||
import {
|
||||
getYearMonthObjectFromString,
|
||||
getYearMonthStringFromObject,
|
||||
getCurrentUnixTime,
|
||||
getCurrentYear,
|
||||
getThisYearFirstUnixTime,
|
||||
getYearMonthFirstUnixTime,
|
||||
getYearMonthLastUnixTime
|
||||
} from '@/lib/datetime.js';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'minTime',
|
||||
'maxTime',
|
||||
'title',
|
||||
'hint',
|
||||
'show'
|
||||
],
|
||||
emits: [
|
||||
'update:show',
|
||||
'dateRange:change'
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
let minDate = getThisYearFirstUnixTime();
|
||||
let maxDate = getCurrentUnixTime();
|
||||
|
||||
if (self.minTime) {
|
||||
minDate = getYearMonthObjectFromString(self.minTime);
|
||||
}
|
||||
|
||||
if (self.maxTime) {
|
||||
maxDate = getYearMonthObjectFromString(self.maxTime);
|
||||
}
|
||||
|
||||
return {
|
||||
yearRange: [
|
||||
2000,
|
||||
getCurrentYear() + 1
|
||||
],
|
||||
dateRange: [
|
||||
minDate,
|
||||
maxDate
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUserStore),
|
||||
isDarkMode() {
|
||||
return this.$root.isDarkMode;
|
||||
},
|
||||
firstDayOfWeek() {
|
||||
return this.userStore.currentUserFirstDayOfWeek;
|
||||
},
|
||||
isYearFirst() {
|
||||
return this.$locale.isLongDateMonthAfterYear(this.userStore);
|
||||
},
|
||||
is24Hour() {
|
||||
return this.$locale.isLongTime24HourFormat(this.userStore);
|
||||
},
|
||||
beginDateTime() {
|
||||
return this.$locale.formatUnixTimeToLongYearMonth(this.userStore, getYearMonthFirstUnixTime(this.dateRange[0]));
|
||||
},
|
||||
endDateTime() {
|
||||
return this.$locale.formatUnixTimeToLongYearMonth(this.userStore, getYearMonthLastUnixTime(this.dateRange[1]));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSheetOpen() {
|
||||
if (this.minTime) {
|
||||
this.dateRange[0] = getYearMonthObjectFromString(this.minTime);
|
||||
}
|
||||
|
||||
if (this.maxTime) {
|
||||
this.dateRange[1] = getYearMonthObjectFromString(this.maxTime);
|
||||
}
|
||||
},
|
||||
onSheetClosed() {
|
||||
this.$emit('update:show', false);
|
||||
},
|
||||
confirm() {
|
||||
if (!this.dateRange[0] || !this.dateRange[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dateRange[0].year <= 0 || this.dateRange[0].month < 0 || this.dateRange[1].year <= 0 || this.dateRange[1].month < 0) {
|
||||
this.$toast('Date is too early');
|
||||
return;
|
||||
}
|
||||
|
||||
const minYearMonth = getYearMonthStringFromObject(this.dateRange[0]);
|
||||
const maxYearMonth = getYearMonthStringFromObject(this.dateRange[1]);
|
||||
|
||||
this.$emit('dateRange:change', minYearMonth, maxYearMonth);
|
||||
},
|
||||
cancel() {
|
||||
this.$emit('update:show', false);
|
||||
},
|
||||
getMonthShortName(month) {
|
||||
return this.$locale.getMonthShortName(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.month-range-selection-sheet .dp__main .dp__instance_calendar .dp__overlay.dp--overlay-relative {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.month-range-selection-sheet .dp__main .dp__instance_calendar .dp__overlay.dp--overlay-relative .dp__selection_grid_header .dp--year-mode-picker .dp--arrow-btn-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.month-range-selection-sheet .dp__main .dp__instance_calendar .dp__overlay.dp--overlay-relative .dp__selection_grid_header .dp--year-mode-picker .dp--year-select+.dp--arrow-btn-nav {
|
||||
justify-content: end;
|
||||
}
|
||||
</style>
|
||||
@@ -43,9 +43,12 @@
|
||||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('+')">
|
||||
<span class="numpad-button-text numpad-button-text-normal">+</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputDecimalSeparator()">
|
||||
<f7-button class="numpad-button numpad-button-num" v-if="supportDecimalSeparator" @click="inputDecimalSeparator()">
|
||||
<span class="numpad-button-text numpad-button-text-normal">{{ decimalSeparator }}</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" v-if="!supportDecimalSeparator" @click="inputDoubleNum(0)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">00</span>
|
||||
</f7-button>
|
||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(0)">
|
||||
<span class="numpad-button-text numpad-button-text-normal">0</span>
|
||||
</f7-button>
|
||||
@@ -66,6 +69,7 @@
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
|
||||
import currencyConstants from '@/consts/currency.js';
|
||||
import { isString, isNumber, removeAll } from '@/lib/common.js';
|
||||
|
||||
export default {
|
||||
@@ -73,6 +77,7 @@ export default {
|
||||
'modelValue',
|
||||
'minValue',
|
||||
'maxValue',
|
||||
'currency',
|
||||
'show'
|
||||
],
|
||||
emits: [
|
||||
@@ -94,6 +99,13 @@ export default {
|
||||
decimalSeparator() {
|
||||
return this.$locale.getCurrentDecimalSeparator(this.userStore);
|
||||
},
|
||||
supportDecimalSeparator() {
|
||||
if (!this.currency || !currencyConstants.all[this.currency] || !isNumber(currencyConstants.all[this.currency].fraction)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currencyConstants.all[this.currency].fraction > 0;
|
||||
},
|
||||
currentDisplay() {
|
||||
const previousValue = this.$locale.appendDigitGroupingSymbol(this.userStore, this.previousValue);
|
||||
const currentValue = this.$locale.appendDigitGroupingSymbol(this.userStore, this.currentValue);
|
||||
@@ -129,7 +141,7 @@ export default {
|
||||
return '';
|
||||
}
|
||||
|
||||
let str = this.$locale.formatAmount(userStore, value);
|
||||
let str = this.$locale.formatAmount(userStore, value, this.currency);
|
||||
|
||||
const digitGroupingSymbol = this.$locale.getCurrentDigitGroupingSymbol(userStore);
|
||||
|
||||
@@ -208,6 +220,10 @@ export default {
|
||||
|
||||
this.currentValue = newValue;
|
||||
},
|
||||
inputDoubleNum(num) {
|
||||
this.inputNum(num);
|
||||
this.inputNum(num);
|
||||
},
|
||||
inputDecimalSeparator() {
|
||||
if (this.currentValue.indexOf(this.decimalSeparator) >= 0) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<f7-sheet swipe-to-close swipe-handler=".swipe-handler"
|
||||
:opened="show" :class="{ 'tag-selection-huge-sheet': hugeListItemRows }"
|
||||
:class="heightClass" :opened="show"
|
||||
@sheet:open="onSheetOpen" @sheet:closed="onSheetClosed">
|
||||
<f7-toolbar>
|
||||
<div class="swipe-handler"></div>
|
||||
@@ -8,46 +8,84 @@
|
||||
<f7-link sheet-close :text="$t('Cancel')"></f7-link>
|
||||
</div>
|
||||
<div class="right">
|
||||
<f7-link :text="$t('Done')" @click="save"></f7-link>
|
||||
<f7-link :text="$t('Done')" v-if="allTags && allTags.length && !noAvailableTag" @click="save"></f7-link>
|
||||
<f7-link :class="{'disabled': newTag}"
|
||||
:text="$t('Add')" v-if="!allTags || !allTags.length || noAvailableTag" @click="addNewTag"></f7-link>
|
||||
</div>
|
||||
</f7-toolbar>
|
||||
<f7-page-content>
|
||||
<f7-list class="no-margin-top no-margin-bottom" v-if="!items || !items.length || noAvailableTag">
|
||||
<f7-list class="no-margin-top no-margin-bottom" v-if="(!allTags || !allTags.length || noAvailableTag) && !newTag">
|
||||
<f7-list-item :title="$t('No available tag')"></f7-list-item>
|
||||
</f7-list>
|
||||
<f7-list dividers class="no-margin-top no-margin-bottom" v-else-if="items && items.length && !noAvailableTag">
|
||||
<f7-list dividers class="no-margin-top no-margin-bottom tag-selection-list" v-else-if="(allTags && allTags.length && !noAvailableTag) || newTag">
|
||||
<f7-list-item checkbox
|
||||
:class="isChecked(item.id) ? 'list-item-selected' : ''"
|
||||
:value="item.id"
|
||||
:checked="isChecked(item.id)"
|
||||
:key="item.id"
|
||||
v-for="item in items"
|
||||
v-show="!item.hidden || isChecked(item.id)"
|
||||
@change="changeItemSelection">
|
||||
:class="isChecked(tag.id) ? 'list-item-selected' : ''"
|
||||
:value="tag.id"
|
||||
:checked="isChecked(tag.id)"
|
||||
:key="tag.id"
|
||||
v-for="tag in allTags"
|
||||
v-show="!tag.hidden || isChecked(tag.id)"
|
||||
@change="changeTagSelection">
|
||||
<template #title>
|
||||
<f7-block class="no-padding no-margin">
|
||||
<div class="display-flex">
|
||||
<f7-icon f7="number"></f7-icon>
|
||||
<div class="tag-selection-list-item list-item-valign-middle padding-left-half">
|
||||
{{ item.name }}
|
||||
{{ tag.name }}
|
||||
</div>
|
||||
</div>
|
||||
</f7-block>
|
||||
</template>
|
||||
</f7-list-item>
|
||||
<f7-list-item :title="$t('Add new tag')"
|
||||
v-if="allowAddNewTag && !newTag"
|
||||
@click="addNewTag()">
|
||||
</f7-list-item>
|
||||
<f7-list-item checkbox indeterminate disabled v-if="allowAddNewTag && newTag">
|
||||
<template #media>
|
||||
<f7-icon f7="number"></f7-icon>
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="display-flex">
|
||||
<f7-input class="list-title-input padding-left-half"
|
||||
type="text"
|
||||
:placeholder="$t('Tag Title')"
|
||||
v-model:value="newTag.name"
|
||||
@keyup.enter="saveNewTag()">
|
||||
</f7-input>
|
||||
</div>
|
||||
</template>
|
||||
<template #after>
|
||||
<f7-button class="no-padding"
|
||||
raised fill
|
||||
icon-f7="checkmark_alt"
|
||||
color="blue"
|
||||
@click="saveNewTag()">
|
||||
</f7-button>
|
||||
<f7-button class="no-padding margin-left-half"
|
||||
raised fill
|
||||
icon-f7="xmark"
|
||||
color="gray"
|
||||
@click="cancelSaveNewTag()">
|
||||
</f7-button>
|
||||
</template>
|
||||
</f7-list-item>
|
||||
</f7-list>
|
||||
</f7-page-content>
|
||||
</f7-sheet>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapStores } from 'pinia';
|
||||
import { useTransactionTagsStore } from '@/stores/transactionTag.js';
|
||||
|
||||
import { copyArrayTo } from '@/lib/common.js';
|
||||
import { scrollToSelectedItem } from '@/lib/ui.mobile.js';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'modelValue',
|
||||
'items',
|
||||
'allowAddNewTag',
|
||||
'show'
|
||||
],
|
||||
emits: [
|
||||
@@ -56,18 +94,22 @@ export default {
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
const transactionTagsStore = useTransactionTagsStore();
|
||||
|
||||
return {
|
||||
selectedItemIds: copyArrayTo(self.modelValue, [])
|
||||
heightClass: self.getHeightClass(transactionTagsStore.allTransactionTags),
|
||||
selectedItemIds: copyArrayTo(self.modelValue, []),
|
||||
newTag: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hugeListItemRows() {
|
||||
return this.items.length > 10;
|
||||
...mapStores(useTransactionTagsStore),
|
||||
allTags() {
|
||||
return this.transactionTagsStore.allTransactionTags;
|
||||
},
|
||||
noAvailableTag() {
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (!this.items[i].hidden) {
|
||||
for (let i = 0; i < this.allTags.length; i++) {
|
||||
if (!this.allTags[i].hidden) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -82,12 +124,14 @@ export default {
|
||||
},
|
||||
onSheetOpen(event) {
|
||||
this.selectedItemIds = copyArrayTo(this.modelValue, []);
|
||||
this.newTag = null;
|
||||
scrollToSelectedItem(event.$el, '.page-content', 'li.list-item-selected');
|
||||
},
|
||||
onSheetClosed() {
|
||||
this.$emit('update:show', false);
|
||||
this.heightClass = this.getHeightClass(this.allTags);
|
||||
},
|
||||
changeItemSelection(e) {
|
||||
changeTagSelection(e) {
|
||||
const tagId = e.target.value;
|
||||
|
||||
if (e.target.checked) {
|
||||
@@ -107,6 +151,36 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
addNewTag() {
|
||||
this.newTag = {
|
||||
name: ''
|
||||
};
|
||||
},
|
||||
saveNewTag() {
|
||||
const self = this;
|
||||
|
||||
self.$showLoading();
|
||||
|
||||
self.transactionTagsStore.saveTag({
|
||||
tag: self.newTag
|
||||
}).then(tag => {
|
||||
self.$hideLoading();
|
||||
self.newTag = null;
|
||||
|
||||
if (tag && tag.id) {
|
||||
self.selectedItemIds.push(tag.id);
|
||||
}
|
||||
}).catch(error => {
|
||||
self.$hideLoading();
|
||||
|
||||
if (!error.processed) {
|
||||
self.$toast(error.message || error);
|
||||
}
|
||||
});
|
||||
},
|
||||
cancelSaveNewTag() {
|
||||
this.newTag = null;
|
||||
},
|
||||
isChecked(itemId) {
|
||||
for (let i = 0; i < this.selectedItemIds.length; i++) {
|
||||
if (this.selectedItemIds[i] === itemId) {
|
||||
@@ -115,6 +189,15 @@ export default {
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
getHeightClass(allTags) {
|
||||
if (allTags && allTags.length > 10) {
|
||||
return 'tag-selection-huge-sheet';
|
||||
} else if (allTags && allTags.length > 6) {
|
||||
return 'tag-selection-large-sheet';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,11 +205,19 @@ export default {
|
||||
|
||||
<style>
|
||||
@media (min-height: 630px) {
|
||||
.tag-selection-large-sheet {
|
||||
height: 310px;
|
||||
}
|
||||
|
||||
.tag-selection-huge-sheet {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-selection-list.list .item-media + .item-inner {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.tag-selection-list-item {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<f7-list class="statistics-list-item skeleton-text" v-if="loading">
|
||||
<f7-list-item link="#" :key="itemIdx" v-for="itemIdx in [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ]">
|
||||
<template #media>
|
||||
<div class="display-flex no-padding-horizontal">
|
||||
<div class="display-flex align-items-center statistics-icon">
|
||||
<f7-icon f7="app_fill"></f7-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="statistics-list-item-text">
|
||||
<span>Date Range</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #after>
|
||||
<span>0.00 USD</span>
|
||||
</template>
|
||||
<template #inner-end>
|
||||
<div class="statistics-item-end">
|
||||
<div class="statistics-percent-line">
|
||||
<f7-progressbar></f7-progressbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</f7-list-item>
|
||||
</f7-list>
|
||||
|
||||
<f7-list v-else-if="!loading && (!allDisplayDataItems || !allDisplayDataItems.data || !allDisplayDataItems.data.length)">
|
||||
<f7-list-item :title="$t('No transaction data')"></f7-list-item>
|
||||
</f7-list>
|
||||
|
||||
<f7-list v-else-if="!loading && allDisplayDataItems && allDisplayDataItems.data && allDisplayDataItems.data.length">
|
||||
<f7-list-item v-if="allDisplayDataItems.legends && allDisplayDataItems.legends.length > 1">
|
||||
<div class="display-flex" style="flex-wrap: wrap">
|
||||
<div class="trends-bar-chart-legend display-flex align-items-center"
|
||||
:class="{ 'trends-bar-chart-legend-unselected': !!unselectedLegends[legend.id] }"
|
||||
:key="idx"
|
||||
v-for="(legend, idx) in allDisplayDataItems.legends"
|
||||
@click="toggleLegend(legend)">
|
||||
<f7-icon f7="app_fill" class="trends-bar-chart-legend-icon" :style="{ 'color': unselectedLegends[legend.id] ? '' : legend.color }"></f7-icon>
|
||||
<span class="trends-bar-chart-legend-text">{{ legend.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</f7-list-item>
|
||||
<f7-list-item class="statistics-list-item"
|
||||
link="#"
|
||||
:key="idx"
|
||||
v-for="(item, idx) in allDisplayDataItems.data"
|
||||
v-show="!item.hidden"
|
||||
@click="clickItem(item)"
|
||||
>
|
||||
<template #media>
|
||||
<div class="display-flex no-padding-horizontal">
|
||||
<div class="display-flex align-items-center statistics-icon">
|
||||
<f7-icon f7="calendar"></f7-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
<div class="statistics-list-item-text">
|
||||
<span>{{ item.displayDateRange }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #after>
|
||||
<span>{{ getDisplayCurrency(item.totalAmount, defaultCurrency) }}</span>
|
||||
</template>
|
||||
|
||||
<template #inner-end>
|
||||
<div class="statistics-item-end">
|
||||
<div class="statistics-percent-line statistics-multi-percent-line display-flex">
|
||||
<div class="display-inline-flex" :style="{ 'width': (item.percent * data.totalAmount / item.totalPositiveAmount) + '%' }"
|
||||
:key="dataIdx"
|
||||
v-for="(data, dataIdx) in item.items"
|
||||
v-show="data.totalAmount > 0">
|
||||
<f7-progressbar :progress="100" :style="{ '--f7-progressbar-progress-color': (data.color ? data.color : '') } "></f7-progressbar>
|
||||
</div>
|
||||
<div class="display-inline-flex" :style="{ 'width': (100.0 - item.percent) + '%' }"
|
||||
v-if="item.percent < 100.0">
|
||||
<f7-progressbar :progress="0"></f7-progressbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</f7-list-item>
|
||||
</f7-list>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapStores } from 'pinia';
|
||||
import { useSettingsStore } from '@/stores/setting.js';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
|
||||
import colorConstants from '@/consts/color.js';
|
||||
import datetimeConstants from '@/consts/datetime.js';
|
||||
import statisticsConstants from '@/consts/statistics.js';
|
||||
import { isNumber } from '@/lib/common.js';
|
||||
import {
|
||||
getYearMonthFirstUnixTime,
|
||||
getYearMonthLastUnixTime,
|
||||
getDateTypeByDateRange
|
||||
} from '@/lib/datetime.js';
|
||||
import {
|
||||
sortStatisticsItems,
|
||||
getAllDateRanges
|
||||
} from '@/lib/statistics.js';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'loading',
|
||||
'items',
|
||||
'startYearMonth',
|
||||
'endYearMonth',
|
||||
'sortingType',
|
||||
'dateAggregationType',
|
||||
'idField',
|
||||
'nameField',
|
||||
'valueField',
|
||||
'colorField',
|
||||
'hiddenField',
|
||||
'displayOrdersField',
|
||||
'translateName',
|
||||
'defaultCurrency',
|
||||
'enableClickItem'
|
||||
],
|
||||
emits: [
|
||||
'click'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
unselectedLegends: {}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useSettingsStore, useUserStore),
|
||||
allDateRanges: function () {
|
||||
return getAllDateRanges(this.items, this.startYearMonth, this.endYearMonth, this.dateAggregationType);
|
||||
},
|
||||
allDisplayDataItems: function () {
|
||||
const allDateRangeItemsMap = {};
|
||||
const legends = [];
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const item = this.items[i];
|
||||
|
||||
if (!this.hiddenField || item[this.hiddenField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = (this.idField && item[this.idField]) ? item[this.idField] : this.getItemName(item[this.nameField]);
|
||||
|
||||
const legend = {
|
||||
id: id,
|
||||
name: (this.nameField && item[this.nameField]) ? this.getItemName(item[this.nameField]) : id,
|
||||
color: this.getColor(item[this.colorField] ? item[this.colorField] : colorConstants.defaultChartColors[i % colorConstants.defaultChartColors.length]),
|
||||
displayOrders: (this.displayOrdersField && item[this.displayOrdersField]) ? item[this.displayOrdersField] : [0]
|
||||
};
|
||||
|
||||
legends.push(legend);
|
||||
|
||||
if (this.unselectedLegends[id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dateRangeItemMap = {};
|
||||
|
||||
for (let j = 0; j < item.items.length; j++) {
|
||||
const dataItem = item.items[j];
|
||||
let dateRangeKey = '';
|
||||
|
||||
if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) {
|
||||
dateRangeKey = dataItem.year;
|
||||
} else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) {
|
||||
dateRangeKey = `${dataItem.year}-${Math.floor((dataItem.month - 1) / 3) + 1}`;
|
||||
} else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) {
|
||||
dateRangeKey = `${dataItem.year}-${dataItem.month}`;
|
||||
}
|
||||
|
||||
if (dateRangeItemMap[dateRangeKey]) {
|
||||
dateRangeItemMap[dateRangeKey].totalAmount += (this.valueField && isNumber(dataItem[this.valueField])) ? dataItem[this.valueField] : 0;
|
||||
} else {
|
||||
const allDataItems = allDateRangeItemsMap[dateRangeKey] || [];
|
||||
const finalDataItem = Object.assign({}, legend, {
|
||||
totalAmount: (this.valueField && isNumber(dataItem[this.valueField])) ? dataItem[this.valueField] : 0
|
||||
});
|
||||
|
||||
allDataItems.push(finalDataItem);
|
||||
dateRangeItemMap[dateRangeKey] = finalDataItem;
|
||||
allDateRangeItemsMap[dateRangeKey] = allDataItems;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalDataItems = [];
|
||||
let maxTotalAmount = 0;
|
||||
|
||||
for (let i = 0; i < this.allDateRanges.length; i++) {
|
||||
const dateRange = this.allDateRanges[i];
|
||||
let dateRangeKey = '';
|
||||
|
||||
if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) {
|
||||
dateRangeKey = dateRange.year;
|
||||
} else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) {
|
||||
dateRangeKey = `${dateRange.year}-${dateRange.quarter}`;
|
||||
} else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) {
|
||||
dateRangeKey = `${dateRange.year}-${dateRange.month + 1}`;
|
||||
}
|
||||
|
||||
let displayDateRange = '';
|
||||
|
||||
if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) {
|
||||
displayDateRange = this.$locale.formatUnixTimeToShortYear(this.userStore, dateRange.minUnixTime);
|
||||
} else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) {
|
||||
displayDateRange = this.$locale.formatYearQuarter(dateRange.year, dateRange.quarter);
|
||||
} else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) {
|
||||
displayDateRange = this.$locale.formatUnixTimeToShortYearMonth(this.userStore, dateRange.minUnixTime);
|
||||
}
|
||||
|
||||
const dataItems = allDateRangeItemsMap[dateRangeKey] || [];
|
||||
let totalAmount = 0;
|
||||
let totalPositiveAmount = 0;
|
||||
|
||||
sortStatisticsItems(dataItems, this.sortingType);
|
||||
|
||||
for (let j = 0; j < dataItems.length; j++) {
|
||||
if (dataItems[j].totalAmount > 0) {
|
||||
totalPositiveAmount += dataItems[j].totalAmount;
|
||||
}
|
||||
|
||||
totalAmount += dataItems[j].totalAmount;
|
||||
}
|
||||
|
||||
if (totalAmount > maxTotalAmount) {
|
||||
maxTotalAmount = totalAmount;
|
||||
}
|
||||
|
||||
finalDataItems.push({
|
||||
dateRange: dateRange,
|
||||
displayDateRange: displayDateRange,
|
||||
items: dataItems,
|
||||
totalAmount: totalAmount,
|
||||
totalPositiveAmount: totalPositiveAmount
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < finalDataItems.length; i++) {
|
||||
if (maxTotalAmount > 0 && finalDataItems[i].totalAmount > 0) {
|
||||
finalDataItems[i].percent = 100.0 * finalDataItems[i].totalAmount / maxTotalAmount;
|
||||
} else {
|
||||
finalDataItems[i].percent = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: finalDataItems,
|
||||
legends: legends
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickItem: function (item) {
|
||||
let itemId = '';
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const item = this.items[i];
|
||||
|
||||
if (!this.hiddenField || item[this.hiddenField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = (this.idField && item[this.idField]) ? item[this.idField] : this.getItemName(item[this.nameField]);
|
||||
|
||||
if (this.unselectedLegends[id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (itemId.length) {
|
||||
itemId += ',';
|
||||
}
|
||||
|
||||
itemId += id;
|
||||
}
|
||||
|
||||
const dateRange = item.dateRange;
|
||||
let minUnixTime = dateRange.minUnixTime;
|
||||
let maxUnixTime = dateRange.maxUnixTime;
|
||||
|
||||
if (this.startYearMonth) {
|
||||
const startMinUnixTime = getYearMonthFirstUnixTime(this.startYearMonth);
|
||||
|
||||
if (startMinUnixTime > minUnixTime) {
|
||||
minUnixTime = startMinUnixTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.endYearMonth) {
|
||||
const endMaxUnixTime = getYearMonthLastUnixTime(this.endYearMonth);
|
||||
|
||||
if (endMaxUnixTime < maxUnixTime) {
|
||||
maxUnixTime = endMaxUnixTime;
|
||||
}
|
||||
}
|
||||
|
||||
const dateRangeType = getDateTypeByDateRange(minUnixTime, maxUnixTime, this.userStore.currentUserFirstDayOfWeek, datetimeConstants.allDateRangeScenes.Normal);
|
||||
|
||||
this.$emit('click', {
|
||||
itemId: itemId,
|
||||
dateRange: {
|
||||
minTime: minUnixTime,
|
||||
maxTime: maxUnixTime,
|
||||
type: dateRangeType
|
||||
}
|
||||
});
|
||||
},
|
||||
toggleLegend(legend) {
|
||||
if (this.unselectedLegends[legend.id]) {
|
||||
delete this.unselectedLegends[legend.id];
|
||||
} else {
|
||||
this.unselectedLegends[legend.id] = true;
|
||||
}
|
||||
},
|
||||
getColor: function (color) {
|
||||
if (color && color !== colorConstants.defaultColor) {
|
||||
color = '#' + color;
|
||||
}
|
||||
|
||||
return color;
|
||||
},
|
||||
getItemName(name) {
|
||||
return this.translateName ? this.$t(name) : name;
|
||||
},
|
||||
getDisplayCurrency(value, currencyCode) {
|
||||
return this.$locale.formatAmountWithCurrency(this.settingsStore, this.userStore, value, currencyCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.trends-bar-chart-legend {
|
||||
margin-right: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trends-bar-chart-legend-icon.f7-icons {
|
||||
font-size: var(--ebk-trends-bar-chart-legend-icon-font-size);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.trends-bar-chart-legend-unselected .trends-bar-chart-legend-icon.f7-icons {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.trends-bar-chart-legend-text {
|
||||
font-size: var(--ebk-trends-bar-chart-legend-text-font-size);
|
||||
}
|
||||
|
||||
.trends-bar-chart-legend-unselected .trends-bar-chart-legend-text {
|
||||
color: #cccccc;
|
||||
}
|
||||
</style>
|
||||
+25
-11
@@ -1,49 +1,61 @@
|
||||
const allAccountCategories = [
|
||||
{
|
||||
const allAccountCategories = {
|
||||
Cash: {
|
||||
id: 1,
|
||||
name: 'Cash',
|
||||
defaultAccountIconId: '1'
|
||||
},
|
||||
{
|
||||
CheckingAccount: {
|
||||
id: 2,
|
||||
name: 'Checking Account',
|
||||
defaultAccountIconId: '100'
|
||||
},
|
||||
{
|
||||
SavingsAccount: {
|
||||
id: 8,
|
||||
name: 'Savings Account',
|
||||
defaultAccountIconId: '100'
|
||||
},
|
||||
{
|
||||
CreditCard: {
|
||||
id: 3,
|
||||
name: 'Credit Card',
|
||||
defaultAccountIconId: '100'
|
||||
},
|
||||
{
|
||||
VirtualAccount: {
|
||||
id: 4,
|
||||
name: 'Virtual Account',
|
||||
defaultAccountIconId: '500'
|
||||
},
|
||||
{
|
||||
DebtAccount: {
|
||||
id: 5,
|
||||
name: 'Debt Account',
|
||||
defaultAccountIconId: '600'
|
||||
},
|
||||
{
|
||||
Receivables: {
|
||||
id: 6,
|
||||
name: 'Receivables',
|
||||
defaultAccountIconId: '700'
|
||||
},
|
||||
{
|
||||
CertificatePfDeposit: {
|
||||
id: 9,
|
||||
name: 'Certificate of Deposit',
|
||||
defaultAccountIconId: '110'
|
||||
},
|
||||
{
|
||||
InvestmentAccount: {
|
||||
id: 7,
|
||||
name: 'Investment Account',
|
||||
defaultAccountIconId: '800'
|
||||
}
|
||||
};
|
||||
|
||||
const allAccountCategoriesArray = [
|
||||
allAccountCategories.Cash,
|
||||
allAccountCategories.CheckingAccount,
|
||||
allAccountCategories.SavingsAccount,
|
||||
allAccountCategories.CreditCard,
|
||||
allAccountCategories.VirtualAccount,
|
||||
allAccountCategories.DebtAccount,
|
||||
allAccountCategories.Receivables,
|
||||
allAccountCategories.CertificatePfDeposit,
|
||||
allAccountCategories.InvestmentAccount
|
||||
];
|
||||
const allAccountTypes = {
|
||||
SingleAccount: 1,
|
||||
@@ -60,7 +72,9 @@ const allAccountTypesArray = [
|
||||
];
|
||||
|
||||
export default {
|
||||
allCategories: allAccountCategories,
|
||||
cashCategoryType: allAccountCategories.Cash.id,
|
||||
creditCardCategoryType: allAccountCategories.CreditCard.id,
|
||||
allCategories: allAccountCategoriesArray,
|
||||
allAccountTypes: allAccountTypes,
|
||||
allAccountTypesArray: allAccountTypesArray,
|
||||
};
|
||||
|
||||
+196
-1
File diff suppressed because it is too large
Load Diff
+24
-1
@@ -252,6 +252,22 @@ const allDateRanges = {
|
||||
[allDateRangeScenes.TrendAnalysis]: true
|
||||
}
|
||||
},
|
||||
PreviousBillingCycle: {
|
||||
type: 51,
|
||||
name: 'Previous Billing Cycle',
|
||||
isBillingCycle: true,
|
||||
availableScenes: {
|
||||
[allDateRangeScenes.Normal]: true
|
||||
}
|
||||
},
|
||||
CurrentBillingCycle: {
|
||||
type: 52,
|
||||
name: 'Current Billing Cycle',
|
||||
isBillingCycle: true,
|
||||
availableScenes: {
|
||||
[allDateRangeScenes.Normal]: true
|
||||
}
|
||||
},
|
||||
RecentTwelveMonths: {
|
||||
type: 101,
|
||||
name: 'Recent 12 months',
|
||||
@@ -316,7 +332,8 @@ const allDateRangesMap = {
|
||||
[allDateRanges.LastMonth.type]: allDateRanges.LastMonth,
|
||||
[allDateRanges.ThisYear.type]: allDateRanges.ThisYear,
|
||||
[allDateRanges.LastYear.type]: allDateRanges.LastYear,
|
||||
[allDateRanges.RecentTwelveMonths.type]: allDateRanges.RecentTwelveMonths,
|
||||
[allDateRanges.PreviousBillingCycle.type]: allDateRanges.PreviousBillingCycle,
|
||||
[allDateRanges.CurrentBillingCycle.type]: allDateRanges.CurrentBillingCycle,
|
||||
[allDateRanges.RecentTwentyFourMonths.type]: allDateRanges.RecentTwentyFourMonths,
|
||||
[allDateRanges.RecentThirtySixMonths.type]: allDateRanges.RecentThirtySixMonths,
|
||||
[allDateRanges.RecentTwoYears.type]: allDateRanges.RecentTwoYears,
|
||||
@@ -325,6 +342,11 @@ const allDateRangesMap = {
|
||||
[allDateRanges.Custom.type]: allDateRanges.Custom
|
||||
};
|
||||
|
||||
const allBillingCycleDateRangesMap = {
|
||||
[allDateRanges.PreviousBillingCycle.type]: allDateRanges.PreviousBillingCycle,
|
||||
[allDateRanges.CurrentBillingCycle.type]: allDateRanges.CurrentBillingCycle
|
||||
};
|
||||
|
||||
const defaultFirstDayOfWeek = allWeekDays.Sunday.type;
|
||||
const defaultLongDateFormat = allLongDateFormat.YYYYMMDD;
|
||||
const defaultShortDateFormat = allShortDateFormat.YYYYMMDD;
|
||||
@@ -349,6 +371,7 @@ export default {
|
||||
allDateRangeScenes: allDateRangeScenes,
|
||||
allDateRanges: allDateRanges,
|
||||
allDateRangesMap: allDateRangesMap,
|
||||
allBillingCycleDateRangesMap: allBillingCycleDateRangesMap,
|
||||
defaultFirstDayOfWeek: defaultFirstDayOfWeek,
|
||||
defaultLongDateFormat: defaultLongDateFormat,
|
||||
defaultShortDateFormat: defaultShortDateFormat,
|
||||
|
||||
@@ -140,6 +140,8 @@ const allAmountFilterTypeMap = {
|
||||
};
|
||||
|
||||
const defaultDecimalSeparator = allDecimalSeparator.Dot;
|
||||
const defaultDecimalNumberCount = 2;
|
||||
const maxSupportedDecimalNumberCount = 2;
|
||||
const defaultDigitGroupingSymbol = allDigitGroupingSymbol.Comma;
|
||||
const defaultDigitGroupingType = allDigitGroupingType.ThousandsSeparator;
|
||||
const defaultValue = 0;
|
||||
@@ -158,6 +160,8 @@ export default {
|
||||
allAmountFilterTypeArray: allAmountFilterTypeArray,
|
||||
allAmountFilterTypeMap: allAmountFilterTypeMap,
|
||||
defaultDecimalSeparator: defaultDecimalSeparator,
|
||||
defaultDecimalNumberCount: defaultDecimalNumberCount,
|
||||
maxSupportedDecimalNumberCount: maxSupportedDecimalNumberCount,
|
||||
defaultDigitGroupingSymbol: defaultDigitGroupingSymbol,
|
||||
defaultDigitGroupingType: defaultDigitGroupingType,
|
||||
defaultValue: defaultValue,
|
||||
|
||||
@@ -169,6 +169,29 @@ const allSortingTypesArray = [
|
||||
|
||||
const defaultSortingType = allSortingTypes.Amount.type;
|
||||
|
||||
const allDateAggregationTypes = {
|
||||
Month: {
|
||||
type: 0,
|
||||
name: 'Aggregate by Month'
|
||||
},
|
||||
Quarter: {
|
||||
type: 1,
|
||||
name: 'Aggregate by Quarter'
|
||||
},
|
||||
Year: {
|
||||
type: 2,
|
||||
name: 'Aggregate by Year'
|
||||
}
|
||||
};
|
||||
|
||||
const allDateAggregationTypesArray = [
|
||||
allDateAggregationTypes.Month,
|
||||
allDateAggregationTypes.Quarter,
|
||||
allDateAggregationTypes.Year
|
||||
]
|
||||
|
||||
const defaultDateAggregationType = allDateAggregationTypes.Month.type;
|
||||
|
||||
export default {
|
||||
allAnalysisTypes: allAnalysisTypes,
|
||||
allCategoricalChartTypes: allCategoricalChartTypes,
|
||||
@@ -185,4 +208,7 @@ export default {
|
||||
allSortingTypes: allSortingTypes,
|
||||
allSortingTypesArray: allSortingTypesArray,
|
||||
defaultSortingType: defaultSortingType,
|
||||
allDateAggregationTypes: allDateAggregationTypes,
|
||||
allDateAggregationTypesArray: allDateAggregationTypesArray,
|
||||
defaultDateAggregationType: defaultDateAggregationType,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Reference: https://github.com/nodatime/nodatime/blob/master/data/cldr/windowsZones-38-1.xml
|
||||
// Reference: https://github.com/nodatime/nodatime/blob/main/data/cldr/windowsZones-45.xml
|
||||
const allAvailableTimezones = [
|
||||
// UTC-12:00
|
||||
{
|
||||
@@ -278,6 +278,10 @@ const allAvailableTimezones = [
|
||||
displayName: 'Jerusalem',
|
||||
timezoneName: 'Asia/Jerusalem'
|
||||
},
|
||||
{
|
||||
displayName: 'Juba',
|
||||
timezoneName: 'Asia/Juba'
|
||||
},
|
||||
{
|
||||
displayName: 'Kaliningrad',
|
||||
timezoneName: 'Europe/Kaliningrad'
|
||||
@@ -352,10 +356,6 @@ const allAvailableTimezones = [
|
||||
displayName: 'Tbilisi',
|
||||
timezoneName: 'Asia/Tbilisi'
|
||||
},
|
||||
{
|
||||
displayName: 'Volgograd',
|
||||
timezoneName: 'Europe/Volgograd'
|
||||
},
|
||||
{
|
||||
displayName: 'Yerevan',
|
||||
timezoneName: 'Asia/Yerevan'
|
||||
|
||||
@@ -36,6 +36,27 @@ const allTransactionEditScopeTypes = {
|
||||
}
|
||||
};
|
||||
|
||||
const allTransactionTagFilterTypes = {
|
||||
HasAny: {
|
||||
type: 0,
|
||||
name: 'With Any Selected Tags'
|
||||
},
|
||||
HasAll: {
|
||||
type: 1,
|
||||
name: 'With All Selected Tags'
|
||||
},
|
||||
NotHasAny: {
|
||||
type: 2,
|
||||
name: 'Without Any Selected Tags'
|
||||
},
|
||||
NotHasAll: {
|
||||
type: 3,
|
||||
name: 'Without All Selected Tags'
|
||||
}
|
||||
};
|
||||
|
||||
const defaultTransactionTagFilterType = allTransactionTagFilterTypes.HasAny;
|
||||
|
||||
const minAmountNumber = -99999999999; // -999999999.99
|
||||
const maxAmountNumber = 99999999999; // 999999999.99
|
||||
const maxPictureCount = 10;
|
||||
@@ -43,6 +64,8 @@ const maxPictureCount = 10;
|
||||
export default {
|
||||
allTransactionTypes: allTransactionTypes,
|
||||
allTransactionEditScopeTypes: allTransactionEditScopeTypes,
|
||||
allTransactionTagFilterTypes: allTransactionTagFilterTypes,
|
||||
defaultTransactionTagFilterType: defaultTransactionTagFilterType,
|
||||
minAmountNumber: minAmountNumber,
|
||||
maxAmountNumber: maxAmountNumber,
|
||||
maxPictureCount: maxPictureCount,
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="img/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png">
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="img/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<script src="./server_settings.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="img/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png">
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="img/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<script src="./server_settings.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
||||
+3
-1
@@ -10,7 +10,9 @@ export function setAccountModelByAnotherAccount(account, account2) {
|
||||
account.color = account2.color;
|
||||
account.currency = account2.currency;
|
||||
account.balance = account2.balance;
|
||||
account.balanceTime = account2.balanceTime;
|
||||
account.comment = account2.comment;
|
||||
account.creditCardStatementDate = account2.creditCardStatementDate;
|
||||
account.visible = !account2.hidden;
|
||||
}
|
||||
|
||||
@@ -241,7 +243,7 @@ export function getAllFilteredAccountsBalance(categorizedAccounts, accountFilter
|
||||
isLiability: account.isLiability,
|
||||
currency: account.currency
|
||||
});
|
||||
} else if (account.type === accountConstants.allAccountTypes.MultiSubAccounts) {
|
||||
} else if (account.type === accountConstants.allAccountTypes.MultiSubAccounts && account.subAccounts) {
|
||||
for (let subAccountIdx = 0; subAccountIdx < account.subAccounts.length; subAccountIdx++) {
|
||||
const subAccount = account.subAccounts[subAccountIdx];
|
||||
|
||||
|
||||
@@ -279,6 +279,48 @@ export function getFirstAvailableSubCategoryId(categories, categoryId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function isNoAvailableCategory(categories, showHidden) {
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
if (showHidden || !categories[i].hidden) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getAvailableCategoryCount(categories, showHidden) {
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
if (showHidden || !categories[i].hidden) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function getFirstShowingId(categories, showHidden) {
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
if (showHidden || !categories[i].hidden) {
|
||||
return categories[i].id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLastShowingId(categories, showHidden) {
|
||||
for (let i = categories.length - 1; i >= 0; i--) {
|
||||
if (showHidden || !categories[i].hidden) {
|
||||
return categories[i].id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasAnyAvailableCategory(allTransactionCategories, showHidden) {
|
||||
for (let type in allTransactionCategories) {
|
||||
if (!Object.prototype.hasOwnProperty.call(allTransactionCategories, type)) {
|
||||
|
||||
+6
-1
@@ -155,7 +155,12 @@ export function getObjectOwnFieldCount(object) {
|
||||
}
|
||||
|
||||
export function replaceAll(value, originalValue, targetValue) {
|
||||
return value.replaceAll(new RegExp(originalValue, 'g'), targetValue);
|
||||
// Escape special characters in originalValue to safely use it in a regex pattern.
|
||||
// This ensures that characters like . (dot), * (asterisk), +, ?, etc. are treated literally,
|
||||
// rather than as special regex symbols.
|
||||
const escapedOriginalValue = originalValue.replace(/([.*+?^=!:${}()|\-/\\])/g, '\\$1');
|
||||
|
||||
return value.replaceAll(new RegExp(escapedOriginalValue, 'g'), targetValue);
|
||||
}
|
||||
|
||||
export function removeAll(value, originalValue) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user