Compare commits

...

94 Commits

Author SHA1 Message Date
MaysWind a26397131d check whether the billing cycle is chosen when set custom date range or backward/forward the date range 2024-12-21 23:15:14 +08:00
MaysWind 7659e8f0f7 set date range type to custom when switching account and the statement date of two accounts are different 2024-12-21 22:30:59 +08:00
MaysWind 90b608bdc6 show confirm dialog before switching to desktop version 2024-12-21 22:23:06 +08:00
MaysWind fffe2a1ccb code refactor 2024-12-21 22:13:00 +08:00
MaysWind fd7706de6d remove redundant code 2024-12-21 22:12:26 +08:00
Huỳnh Đức Khoản d0a5c93e49 fix replace all with special characters 2024-12-20 16:55:15 +08:00
MaysWind 263bf08f34 fix typo 2024-12-18 23:11:27 +08:00
MaysWind e050f30efa code refactor 2024-12-18 22:46:05 +08:00
MaysWind c2b1adf588 upgrade golang to 1.23.4, node.js to 20.18.1, alpine base image to 3.21.0 2024-12-18 22:32:37 +08:00
MaysWind 647cd3c33f add billing cycle date range filter 2024-12-16 23:44:20 +08:00
MaysWind 8fdbb39ee4 add date time filter dropdown menu in desktop transaction list page 2024-12-15 23:49:09 +08:00
MaysWind ee029294f1 improve robustness 2024-12-12 08:54:19 +08:00
MaysWind 563e328ce3 sub account cannot set statement date 2024-12-11 23:53:01 +08:00
MaysWind 8f543d7a84 update translation 2024-12-11 23:30:25 +08:00
MaysWind 62e09190f3 credit card account supports statement date 2024-12-10 22:41:06 +08:00
MaysWind 50c774fd78 show add tag button in tag selection sheet when there are no available tags 2024-12-08 22:38:07 +08:00
MaysWind 10e4bcc723 skip specified tests when build snapshot image 2024-12-08 22:20:16 +08:00
MaysWind 964ad6d046 modify style 2024-12-08 21:16:12 +08:00
MaysWind 56fb76017d modify style 2024-12-08 21:16:00 +08:00
MaysWind 5a9141e10c add new tag in transaction edit page / dialog 2024-12-08 21:04:42 +08:00
MaysWind db94282207 statistics analysis supports filtering tags 2024-12-08 18:00:46 +08:00
MaysWind 9f6446c30c make all tags unselected in tag select page / dialog when no tags are selected 2024-12-08 16:29:20 +08:00
MaysWind d570ce361d add missing code 2024-12-08 16:22:11 +08:00
MaysWind 868fcf2c5a code refactor 2024-12-08 13:14:03 +08:00
MaysWind dd35a85316 support transaction tag filter type 2024-12-08 00:43:29 +08:00
MaysWind 5003f8b3a2 not set destination amount automatically when lack of exchange rates data 2024-12-07 16:54:45 +08:00
MaysWind d044f938e3 skip calculating exchange rates when the account balance is 0 2024-12-07 16:48:28 +08:00
MaysWind e549779164 display amounts according to currency decimals number count 2024-12-06 23:56:02 +08:00
MaysWind e2f2b325a6 update language name 2024-12-06 23:16:06 +08:00
MaysWind 9860c1db54 modify style 2024-12-04 22:44:45 +08:00
MaysWind 7d820f5b88 apply the selected legends when jumping to the transaction list page 2024-12-04 22:36:30 +08:00
MaysWind 61d6e5643c modify style 2024-12-04 08:07:47 +08:00
MaysWind b444de591a merge aggregated data items 2024-12-03 23:05:24 +08:00
MaysWind 21c86c9dfa toggle legend for trends bar chart 2024-12-03 22:42:35 +08:00
MaysWind 8e70754533 remove unused code 2024-12-03 22:06:52 +08:00
MaysWind 4270d74338 update timezone info 2024-12-03 21:57:41 +08:00
Huỳnh Đức Khoản db506fa992 Update vi.json 2024-12-03 12:36:14 +08:00
Huỳnh Đức Khoản c1b06eaa6f Update formater 2024-12-03 12:36:14 +08:00
Huỳnh Đức Khoản 6bd1d09fa8 Add Vietnamese
Update index.js

Update vi.json
2024-12-03 12:36:14 +08:00
MaysWind 70da228dcc show legend in trend analysis for mobile version 2024-12-03 00:03:12 +08:00
MaysWind 9888efe437 modify style 2024-12-02 22:18:46 +08:00
MaysWind 65756b62a5 add trend analysis for mobile version 2024-12-01 23:56:42 +08:00
MaysWind 59a0d593d4 code refactor 2024-12-01 23:00:14 +08:00
MaysWind d519b80b61 reset date aggregation type for trend analysis when switching to non-trend analysis 2024-11-24 23:43:54 +08:00
MaysWind e92725f38b add the Central Bank of the Republic of Uzbekistan exchange rates data source 2024-11-18 01:01:18 +08:00
MaysWind ec0cb0bbb7 improve robustness and add unit tests 2024-11-18 01:00:57 +08:00
MaysWind a4b26374f4 add Central Bank of Myanmar exchange rates data source 2024-11-17 22:07:53 +08:00
MaysWind dcac6a4bb0 add Central Bank of Hungary exchange rates data source 2024-11-17 21:29:57 +08:00
MaysWind dd6eecb0c2 add debug log 2024-11-17 21:27:22 +08:00
MaysWind fec100a273 use the local language to show the national bank name 2024-11-17 13:16:22 +08:00
MaysWind 8f944b1b46 code refactor 2024-11-17 13:04:07 +08:00
MaysWind 69498003d8 add Bank of Russia exchange rates data source 2024-11-17 01:31:25 +08:00
MaysWind e019f557ff remove unused data in test cases 2024-11-17 01:29:30 +08:00
MaysWind 4b5611ef6c use reader label charset reader for xml deserializing 2024-11-17 01:04:06 +08:00
MaysWind ca44b2cc2c add exchange rates api unit tests 2024-11-17 00:23:49 +08:00
MaysWind 10e0972d79 add Norges Bank exchange rates data source 2024-11-16 23:28:19 +08:00
MaysWind 28908d81a3 change timezone name 2024-11-16 22:35:20 +08:00
MaysWind 0503a50754 change timezone name 2024-11-16 22:34:44 +08:00
MaysWind 65a92042d6 increase the request timeout in frontend if the timeout of requesting third-party exchange rates api exceeds the default time 2024-11-16 21:13:37 +08:00
MaysWind f554fdefd3 add National Bank of Georgia exchange rates data source 2024-11-16 20:51:11 +08:00
MaysWind bdbd4d5302 add National Bank of Romania exchange rates data source 2024-11-16 15:08:34 +08:00
MaysWind 3ee1683349 add unit tests 2024-11-16 15:07:32 +08:00
MaysWind 3a7ad429c2 add unit tests and improve robustness 2024-11-16 14:58:18 +08:00
MaysWind 89bd055f02 add logs 2024-11-15 00:40:14 +08:00
MaysWind 835b3b7b8b not need balance time field in parent account 2024-11-15 00:34:46 +08:00
MaysWind 934f90cdff sort currencies in exchange rates page 2024-11-15 00:22:05 +08:00
MaysWind 92cc683b8e add Danmarks Nationalbank exchange rates data source 2024-11-14 23:46:07 +08:00
MaysWind 80d548e8bd add Swiss National Bank exchange rates data source 2024-11-13 01:46:03 +08:00
MaysWind 7ec1efb85d code refactor 2024-11-13 00:48:35 +08:00
MaysWind f5945a788f add unit tests 2024-11-13 00:08:39 +08:00
MaysWind 2d0e2e0cca add Bank of Israel exchange rates data source 2024-11-12 01:20:14 +08:00
MaysWind bff6ca7e9d support setting the time of the initial balance when creating a new account 2024-11-11 01:27:44 +08:00
MaysWind 06b4960984 fix the failure of creating account with initial balance sometimes 2024-11-11 00:35:33 +08:00
MaysWind 2fe393204b fix the issue that the "all" tab of account with multiple sub accounts are not selected when opening the account list page in desktop version 2024-11-10 23:49:34 +08:00
MaysWind 876950a84e only show date aggregation menu in trend analysis 2024-11-10 21:57:04 +08:00
MaysWind 6292ef9dfb add unit tests 2024-11-10 21:35:31 +08:00
MaysWind 798fb8f937 add unit tests 2024-11-10 21:35:19 +08:00
MaysWind f6dd4c03c3 support custom tips in login page 2024-11-10 20:50:03 +08:00
MaysWind f87fbddef7 code refactor 2024-11-10 17:54:32 +08:00
MaysWind aa2e10440d support modifying user feature restriction by cli 2024-11-10 15:48:32 +08:00
MaysWind 34b0b793ba support default feature restrictions after user registration 2024-11-10 15:24:09 +08:00
MaysWind 1f159bf826 support user features restrictions 2024-11-10 01:44:58 +08:00
MaysWind b8253b6dcc create user token via cli 2024-11-09 23:43:28 +08:00
MaysWind 79fd9070e4 make the time range not exceed the selected range when jumping from trend analysis chart to transaction list page 2024-11-08 17:59:18 +08:00
MaysWind 7b96cd0447 remove unused code 2024-11-08 17:48:57 +08:00
MaysWind 01bc9becc0 code refactor 2024-11-08 17:47:51 +08:00
MaysWind 9a009b73dc fix the incorrect parameter when jumping from the statistics page to the transaction list page 2024-11-08 14:20:49 +08:00
MaysWind fe35cbae49 trend analysis supports aggregating amounts by month / quarter / year 2024-11-06 01:35:42 +08:00
MaysWind c3a880e5f5 keep the day of the month when shifting the date range forward or backward if the selected date range is a full month 2024-11-05 00:55:22 +08:00
MaysWind 1c906113ab remove unused code 2024-11-05 00:17:43 +08:00
MaysWind 6f3dcd958d upgrade third party dependencies 2024-11-04 22:42:33 +08:00
MaysWind 7a9f4cd64f upgrade third party dependencies 2024-11-04 00:37:32 +08:00
MaysWind 9a67af7c55 code refactor 2024-11-04 00:37:04 +08:00
MaysWind 501de6ffef bump version to 0.7.0 2024-11-03 21:11:18 +08:00
164 changed files with 13738 additions and 3241 deletions
-16
View File
@@ -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,
}]
}
}
+2
View File
@@ -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 }}
+3 -1
View File
@@ -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 }}
labels: ${{ steps.meta.outputs.labels }}
+3 -1
View File
@@ -25,4 +25,6 @@ jobs:
file: Dockerfile
context: .
platforms: linux/amd64
push: false
push: false
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
+5 -3
View File
@@ -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
+9 -2
View File
@@ -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
call go test .\... -v
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"
+9 -2
View File
@@ -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
go test ./... -v
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"
+167
View File
@@ -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
View File
@@ -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
View File
@@ -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
+36
View File
@@ -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,
}],
},
}];
+11 -11
View File
@@ -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
+22 -22
View File
@@ -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=
+2395 -2201
View File
File diff suppressed because it is too large Load Diff
+26 -23
View File
@@ -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
View File
@@ -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
}
+8
View File
@@ -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 {
+13 -4
View File
@@ -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 {
+351
View File
@@ -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))
}
}
}
+8
View File
@@ -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
+208
View File
@@ -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('\'')
}
+34
View File
@@ -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
View File
@@ -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++ {
+12
View File
@@ -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
}
+38
View File
@@ -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
}
+76
View File
@@ -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 {
+120
View File
@@ -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))
}
}
+118
View File
@@ -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)
}
+3
View File
@@ -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
View File
@@ -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))
}
+1
View File
@@ -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")
)
+10 -3
View File
@@ -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>" +
"&lt;MNBCurrentExchangeRates&gt;" +
"&lt;Day date=\"2024-11-15\"&gt;" +
"&lt;Rate unit=\"100\" curr=\"JPY\"&gt;247,46&lt;/Rate&gt;" +
"&lt;Rate unit=\"1\" curr=\"USD\"&gt;384,48&lt;/Rate&gt;" +
"&lt;/Day&gt;" +
"&lt;/MNBCurrentExchangeRates&gt;" +
"</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>"+
"&lt;MNBCurrentExchangeRates&gt;"+
"&lt;Day date=\"2024-11-15\"&gt;"+
"</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>"+
"&lt;MNBCurrentExchangeRates&gt;"+
"&lt;Day date=\"2024-11-15\"&gt;"+
"&lt;Rate unit=\"1\" curr=\"XXX\"&gt;1&lt;/Rate&gt;"+
"&lt;/Day&gt;"+
"&lt;/MNBCurrentExchangeRates&gt;"+
"</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>"+
"&lt;MNBCurrentExchangeRates&gt;"+
"&lt;Day date=\"2024-11-15\"&gt;"+
"&lt;Rate unit=\"1\" curr=\"USD\"&gt;&lt;/Rate&gt;"+
"&lt;/Day&gt;"+
"&lt;/MNBCurrentExchangeRates&gt;"+
"</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>"+
"&lt;MNBCurrentExchangeRates&gt;"+
"&lt;Day date=\"2024-11-15\"&gt;"+
"&lt;Rate unit=\"1\" curr=\"USD\"&gt;null&lt;/Rate&gt;"+
"&lt;/Day&gt;"+
"&lt;/MNBCurrentExchangeRates&gt;"+
"</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>"+
"&lt;MNBCurrentExchangeRates&gt;"+
"&lt;Day date=\"2024-11-15\"&gt;"+
"&lt;Rate unit=\"1\" curr=\"USD\"&gt;0&lt;/Rate&gt;"+
"&lt;/Day&gt;"+
"&lt;/MNBCurrentExchangeRates&gt;"+
"</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>"+
"&lt;MNBCurrentExchangeRates&gt;"+
"&lt;Day date=\"2024-11-15\"&gt;"+
"&lt;Rate unit=\"null\" curr=\"USD\"&gt;384,48&lt;/Rate&gt;"+
"&lt;/Day&gt;"+
"&lt;/MNBCurrentExchangeRates&gt;"+
"</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>"+
"&lt;MNBCurrentExchangeRates&gt;"+
"&lt;Day date=\"2024-11-15\"&gt;"+
"&lt;Rate unit=\"0\" curr=\"USD\"&gt;384,48&lt;/Rate&gt;"+
"&lt;/Day&gt;"+
"&lt;/MNBCurrentExchangeRates&gt;"+
"</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 = "Ozbekiston 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)
}
+194
View File
@@ -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)
}
+3
View File
@@ -13,6 +13,9 @@ var AllLanguages = map[string]*LocaleInfo{
"en": {
Content: en,
},
"vi": {
Content: vi,
},
"zh-Hans": {
Content: zhHans,
},
+30
View File
@@ -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.",
},
}
-113
View File
@@ -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)
}
}
+83 -48
View File
@@ -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,36 +71,45 @@ 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"`
Category AccountCategory `json:"category" binding:"required"`
Type AccountType `json:"type" binding:"required"`
Icon int64 `json:"icon,string" binding:"required,min=1"`
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
Currency string `json:"currency" binding:"required,len=3,validCurrency"`
Balance int64 `json:"balance"`
Comment string `json:"comment" binding:"max=255"`
SubAccounts []*AccountCreateRequest `json:"subAccounts" binding:"omitempty"`
ClientSessionId string `json:"clientSessionId"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Category AccountCategory `json:"category" binding:"required"`
Type AccountType `json:"type" binding:"required"`
Icon int64 `json:"icon,string" binding:"required,min=1"`
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"`
}
// AccountModifyRequest represents all parameters of account modification request
type AccountModifyRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Category AccountCategory `json:"category" binding:"required"`
Icon int64 `json:"icon,string" binding:"min=1"`
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
Comment string `json:"comment" binding:"max=255"`
Hidden bool `json:"hidden"`
SubAccounts []*AccountModifyRequest `json:"subAccounts" binding:"omitempty"`
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Category AccountCategory `json:"category" binding:"required"`
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"`
}
// AccountListRequest represents all parameters of account listing request
@@ -133,41 +146,63 @@ type AccountDeleteRequest struct {
// AccountInfoResponse represents a view-object of account
type AccountInfoResponse struct {
Id int64 `json:"id,string"`
Name string `json:"name"`
ParentId int64 `json:"parentId,string"`
Category AccountCategory `json:"category"`
Type AccountType `json:"type"`
Icon int64 `json:"icon,string"`
Color string `json:"color"`
Currency string `json:"currency"`
Balance int64 `json:"balance"`
Comment string `json:"comment"`
DisplayOrder int32 `json:"displayOrder"`
IsAsset bool `json:"isAsset,omitempty"`
IsLiability bool `json:"isLiability,omitempty"`
Hidden bool `json:"hidden"`
SubAccounts AccountInfoResponseSlice `json:"subAccounts,omitempty"`
Id int64 `json:"id,string"`
Name string `json:"name"`
ParentId int64 `json:"parentId,string"`
Category AccountCategory `json:"category"`
Type AccountType `json:"type"`
Icon int64 `json:"icon,string"`
Color string `json:"color"`
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"`
Hidden bool `json:"hidden"`
SubAccounts AccountInfoResponseSlice `json:"subAccounts,omitempty"`
}
// ToAccountInfoResponse returns a view-object according to database model
func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
return &AccountInfoResponse{
Id: a.AccountId,
Name: a.Name,
ParentId: a.ParentAccountId,
Category: a.Category,
Type: a.Type,
Icon: a.Icon,
Color: a.Color,
Currency: a.Currency,
Balance: a.Balance,
Comment: a.Comment,
DisplayOrder: a.DisplayOrder,
IsAsset: assetAccountCategory[a.Category],
IsLiability: liabilityAccountCategory[a.Category],
Hidden: a.Hidden,
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,
ParentId: a.ParentAccountId,
Category: a.Category,
Type: a.Type,
Icon: a.Icon,
Color: a.Color,
Currency: a.Currency,
Balance: a.Balance,
Comment: a.Comment,
CreditCardStatementDate: creditCardStatementDate,
DisplayOrder: a.DisplayOrder,
IsAsset: assetAccountCategory[a.Category],
IsLiability: liabilityAccountCategory[a.Category],
Hidden: a.Hidden,
}
}
// 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
+57 -39
View File
@@ -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"`
@@ -140,62 +151,69 @@ type TransactionImportRequest struct {
// TransactionCountRequest represents transaction count request
type TransactionCountRequest struct {
Type TransactionDbType `form:"type" binding:"min=0,max=4"`
CategoryIds string `form:"category_ids"`
AccountIds string `form:"account_ids"`
TagIds string `form:"tag_ids"`
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
Keyword string `form:"keyword"`
MaxTime int64 `form:"max_time" binding:"min=0"`
MinTime int64 `form:"min_time" binding:"min=0"`
Type TransactionDbType `form:"type" binding:"min=0,max=4"`
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"`
MinTime int64 `form:"min_time" binding:"min=0"`
}
// TransactionListByMaxTimeRequest represents all parameters of transaction listing by max time request
type TransactionListByMaxTimeRequest struct {
Type TransactionDbType `form:"type" binding:"min=0,max=4"`
CategoryIds string `form:"category_ids"`
AccountIds string `form:"account_ids"`
TagIds string `form:"tag_ids"`
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
Keyword string `form:"keyword"`
MaxTime int64 `form:"max_time" binding:"min=0"`
MinTime int64 `form:"min_time" binding:"min=0"`
Page int32 `form:"page" binding:"min=0"`
Count int32 `form:"count" binding:"required,min=1,max=50"`
WithCount bool `form:"with_count"`
WithPictures bool `form:"with_pictures"`
TrimAccount bool `form:"trim_account"`
TrimCategory bool `form:"trim_category"`
TrimTag bool `form:"trim_tag"`
Type TransactionDbType `form:"type" binding:"min=0,max=4"`
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"`
MinTime int64 `form:"min_time" binding:"min=0"`
Page int32 `form:"page" binding:"min=0"`
Count int32 `form:"count" binding:"required,min=1,max=50"`
WithCount bool `form:"with_count"`
WithPictures bool `form:"with_pictures"`
TrimAccount bool `form:"trim_account"`
TrimCategory bool `form:"trim_category"`
TrimTag bool `form:"trim_tag"`
}
// TransactionListInMonthByPageRequest represents all parameters of transaction listing by month request
type TransactionListInMonthByPageRequest struct {
Year int32 `form:"year" binding:"required,min=1"`
Month int32 `form:"month" binding:"required,min=1"`
Type TransactionDbType `form:"type" binding:"min=0,max=4"`
CategoryIds string `form:"category_ids"`
AccountIds string `form:"account_ids"`
TagIds string `form:"tag_ids"`
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
Keyword string `form:"keyword"`
WithPictures bool `form:"with_pictures"`
TrimAccount bool `form:"trim_account"`
TrimCategory bool `form:"trim_category"`
TrimTag bool `form:"trim_tag"`
Year int32 `form:"year" binding:"required,min=1"`
Month int32 `form:"month" binding:"required,min=1"`
Type TransactionDbType `form:"type" binding:"min=0,max=4"`
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"`
TrimAccount bool `form:"trim_account"`
TrimCategory bool `form:"trim_category"`
TrimTag bool `form:"trim_tag"`
}
// TransactionStatisticRequest represents all parameters of transaction statistic request
type TransactionStatisticRequest struct {
StartTime int64 `form:"start_time" binding:"min=0"`
EndTime int64 `form:"end_time" binding:"min=0"`
UseTransactionTimezone bool `form:"use_transaction_timezone"`
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
UseTransactionTimezone bool `form:"use_transaction_timezone"`
TagIds string `form:"tag_ids"`
TagFilterType TransactionTagFilterType `form:"tag_filter_type" binding:"min=0,max=3"`
UseTransactionTimezone bool `form:"use_transaction_timezone"`
}
// TransactionAmountsRequest represents all parameters of transaction amounts request
+9
View File
@@ -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{
+88
View File
@@ -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{
+1
View File
@@ -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"`
+34 -7
View File
@@ -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 {
return err
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
View File
@@ -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) {
+50 -56
View File
@@ -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})
if maxTransactionTime > 0 {
condition = condition.And(builder.Lte{"transaction_time": maxTransactionTime})
}
if minTransactionTime > 0 {
condition = condition.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)
}
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})
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})
}
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
}
if len(tagIds) < 1 {
return sess
}
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)))
}
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 {
+69
View File
@@ -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 == "" {
+84 -18
View File
@@ -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
@@ -312,10 +333,11 @@ type Config struct {
CustomMapTileServerDefaultZoomLevel uint8
// Exchange Rates
ExchangeRatesDataSource string
ExchangeRatesRequestTimeout uint32
ExchangeRatesProxy string
ExchangeRatesSkipTLSVerify bool
ExchangeRatesDataSource string
ExchangeRatesRequestTimeout uint32
ExchangeRatesRequestTimeoutExceedDefaultValue bool
ExchangeRatesProxy string
ExchangeRatesSkipTLSVerify bool
}
// LoadConfiguration loads setting config from given config file path
@@ -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),
-3
View File
@@ -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
}
},
-3
View File
@@ -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',
+16 -6
View File
@@ -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;
+100 -53
View File
@@ -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}`;
}
const dataItems = dateRangeAmountMap[dateRangeKey] || [];
dataItems.push(dataItem);
dateRangeAmountMap[dateRangeKey] = dataItems;
}
for (let j = 0; j < this.allYearMonthTimes.length; j++) {
const yearMonth = getYearMonthStringFromObject(this.allYearMonthTimes[j]);
const dataItem = yearMonthDataMap[yearMonth];
let amount = 0;
for (let j = 0; j < this.allDateRanges.length; j++) {
const dateRange = this.allDateRanges[j];
let dateRangeKey = '';
if (dataItem && isNumber(dataItem[this.valueField])) {
amount = dataItem[this.valueField];
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>
+18 -2
View File
@@ -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">&plus;</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;
+363
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -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,
+4
View File
@@ -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,
+26
View File
@@ -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,
};
+5 -5
View File
@@ -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'
+23
View File
@@ -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,
+1
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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];
+42
View File
@@ -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
View File
@@ -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