Compare commits

..

93 Commits

Author SHA1 Message Date
MaysWind 1f7411a002 provide officially built macOS packages 2026-05-13 23:16:55 +08:00
MaysWind a739498ef7 fix some sub directories were incorrectly ignored automatically 2026-05-13 22:53:03 +08:00
MaysWind e6bb128cda check whether every transactions are editable when moving all transactions in an account 2026-05-13 01:13:03 +08:00
MaysWind b9855faf38 fetch transactions in batches when retrieving transaction lists through the MCP tool 2026-05-13 00:37:25 +08:00
MaysWind ea9fce9bae remove unused code 2026-05-12 23:47:34 +08:00
MaysWind 7557c1014d append boolean parameter values to request parameters and print them in lowercase 2026-05-12 00:41:16 +08:00
MaysWind aee8757ad5 add must_have_pictures parameter to the ezBookkeeping API Tools script 2026-05-12 00:34:38 +08:00
MaysWind d0f76fea22 support configuring the default behavior of the reconciliation statement button and the default time range for the reconciliation statement page 2026-05-12 00:21:02 +08:00
MaysWind e6c6d02112 show an error message when transaction images fail to load on the transaction details page 2026-05-11 00:39:38 +08:00
MaysWind 563bef69cf add transaction gallery mode in transaction list page 2026-05-11 00:39:13 +08:00
MaysWind 11f2c9fff7 set maximum width for the confirmation dialog 2026-05-10 01:20:34 +08:00
MaysWind 9e0275a11a prompt the user for confirmation before updating the last reconciled time to the current time 2026-05-10 01:18:09 +08:00
MaysWind 231d3210cb improve styling for quick actions 2026-05-10 01:06:51 +08:00
MaysWind 75d801f775 support displaying transactions since the last reconciled time 2026-05-08 01:01:37 +08:00
MaysWind de132dd7fd support last reconciled time for account 2026-05-07 01:17:00 +08:00
MaysWind 39ee47e05a upgrade github actions 2026-05-04 16:00:14 +08:00
MaysWind 0038321781 update the list of currencies supported by the Bank of Canada 2026-05-04 00:39:23 +08:00
MaysWind 7c67d30222 do not attempt to push docker images when the variables of docker repository in actions are incomplete 2026-05-04 00:30:01 +08:00
vigdail da2f1ef410 fix final rate calculation for the National Bank of Kazakhstan (#565) 2026-04-29 08:06:22 +08:00
vigdail 416e7cece1 add the National Bank of Kazakhstan exchange rates data source (#564)
* add the National Bank of Kazakhstan exchange rates data source

* fix import order, sort exchange rate data by country name.

* fix National Bank of Kazakhstan exchange rate reference url

* add integration test for the National Bank of Kazakhstan exchange rate provider
2026-04-28 22:23:41 +08:00
MaysWind 1d5102a015 do not update the list if no delete action is performed 2026-04-26 01:08:27 +08:00
MaysWind 1e38d1b18b support batch update tags for transactions 2026-04-26 00:57:07 +08:00
MaysWind bab7a0041b support batch update accounts for transactions 2026-04-25 23:17:33 +08:00
MaysWind e38ba2ea0a update the label text of the dropdown component 2026-04-25 20:50:16 +08:00
MaysWind 9d25914411 update loading state when refresh the data 2026-04-25 20:28:00 +08:00
MaysWind bb2068f4db reset submitting state when opening the dialog 2026-04-25 20:07:10 +08:00
MaysWind e4e74304b6 support batch deleting transactions 2026-04-25 20:01:16 +08:00
MaysWind de885c963d replace Jest with Vitest 2026-04-25 00:57:53 +08:00
MaysWind 1428ce921c update README.md 2026-04-24 23:57:22 +08:00
MaysWind 60477f7f27 upgrade third party dependencies 2026-04-24 01:18:28 +08:00
MaysWind 9dcbf1aa7e upgrade golang to 1.26.2, node.js to 24.15.0 and alpine base image to 3.23.4 2026-04-24 00:03:09 +08:00
MaysWind 1d5a6562f3 add sunburst chart in insights explorer 2026-04-24 00:01:09 +08:00
MaysWind 0c427e9857 adjust the size and margin of treemap 2026-04-23 23:25:27 +08:00
Albert Brugués 0b984b3c20 update spanish translations (#562) 2026-04-23 22:52:39 +08:00
MaysWind b87a39464e add tree map chart in insights explorer 2026-04-23 01:28:36 +08:00
MaysWind 0d2b3196e6 removed use of eval in posix scripts 2026-04-22 00:23:47 +08:00
MaysWind e172e040f9 fix the page navigation did not work correctly when clicking page numbers above 1000 2026-04-21 00:42:29 +08:00
MaysWind 629dbeeaa4 add digit grouping symbol when formatting numbers 2026-04-21 00:20:19 +08:00
MaysWind 53e515fb92 use the unified number formatting function to format number 2026-04-21 00:14:39 +08:00
MaysWind 9c87436a36 support batch update categories for transactions in insights explorer 2026-04-20 01:05:29 +08:00
MaysWind f56b5c471d adjust chart left margin 2026-04-19 21:59:37 +08:00
MaysWind cd8f746745 heat map and calendar heat map charts support data export in insights explorer 2026-04-19 21:56:44 +08:00
MaysWind d39494a78e add maximum amount share to value metric in insights explorer 2026-04-19 21:18:39 +08:00
MaysWind e69df56874 reorder the code 2026-04-19 21:15:43 +08:00
MaysWind 808cb98002 improve heat map styling in dark mode 2026-04-19 17:41:01 +08:00
MaysWind 892404924c add calendar heat map chart in insights explorer 2026-04-19 17:40:06 +08:00
MaysWind 374132fe7c fix typo 2026-04-19 14:19:25 +08:00
MaysWind 5d8e709070 update the supported currencies based on the exchange rate data source 2026-04-19 09:44:15 +08:00
MaysWind 124962a4f4 adjust the display order of anomaly groups 2026-04-19 01:53:45 +08:00
MaysWind b9b210c591 display axis type name on the tooltip of heatmap 2026-04-19 01:53:35 +08:00
MaysWind 6a4ab4c145 add amount range to axis / category / series in insights explorer 2026-04-19 01:51:56 +08:00
MaysWind e89aa10137 adjust the display order of value metrics 2026-04-18 15:34:01 +08:00
MaysWind d73704af66 display year-over-year and period-over-period growth rates in insights explorer 2026-04-18 14:36:03 +08:00
MaysWind bf7fe0c583 amounts are grouped using the default currency when axis/category or series is set to amount or transfer in amount in insights explorer 2026-04-18 01:23:01 +08:00
MaysWind a6e252c30d fix amounts on some pages were not formatted using the account currency 2026-04-17 23:40:17 +08:00
MaysWind 1e4bb73874 add mean absolute deviation and median absolute deviation to value metric in insights explorer 2026-04-16 01:24:38 +08:00
MaysWind 8f01469a41 add active transaction days and transactions per active day to value metric in insights explorer 2026-04-16 01:24:25 +08:00
MaysWind 7a821abbb6 add skewness and kurtosis to value metric in insights explorer 2026-04-16 01:24:07 +08:00
MaysWind 02d8b132f5 add expense / income ratio and savings rate to value metric in insights explorer 2026-04-15 22:32:45 +08:00
MaysWind c64c60c6a0 support displaying data in percentage format 2026-04-15 22:21:42 +08:00
MaysWind 50472d437a add top 5 amount share and transactions for 80% of amount to value metric in insights explorer 2026-04-15 00:45:59 +08:00
MaysWind 53702e68d8 add total income / total expense / net income to value metric in insights explorer 2026-04-15 00:28:47 +08:00
MaysWind 36d82254d6 add Q1/Q3 amount, 10th/95th/99th percentile amount and top 5 amount sum to value metric in insights explorer 2026-04-15 00:12:02 +08:00
MaysWind 36529abf08 fix incorrect calculations of median and quartiles in some cases, and fix incorrect top 5 amount share calculation 2026-04-14 23:29:20 +08:00
MaysWind c0641b1db5 display thousand separators by default when formatting numbers 2026-04-14 00:49:12 +08:00
MaysWind c2d7bcc5f1 add heat map chart in insights explorer 2026-04-14 00:48:52 +08:00
MaysWind 4af0797051 fix cannot switch between hours, minutes and seconds by pressing the tab (#554) 2026-04-13 22:35:31 +08:00
MaysWind 63ec0e4424 support daily and yearly intervals for scheduled transactions 2026-04-13 01:34:56 +08:00
MaysWind c828db4988 simplify the way to import rtl stylesheets 2026-04-13 00:12:15 +08:00
MaysWind d7151bc7ab support setting the last 1 to 3 days of the month to scheduled transaction frequency 2026-04-13 00:06:45 +08:00
MaysWind 0222f61da6 code refactor 2026-04-12 23:01:27 +08:00
MaysWind ff1158be00 format the transaction hour of day using the user configured short time format 2026-04-12 22:45:01 +08:00
MaysWind f214b7db88 support filtering transactions by time zone minute offset, day of week, day of month, month of year and transaction hour in insights explorer 2026-04-12 22:04:52 +08:00
MaysWind d605a8f4ec fix the transfer in transactions are not included when exporting transactions under some conditions (#550) 2026-04-12 01:09:44 +08:00
MaysWind 5d333a4e74 display specific comparison details in the period over period title within the tooltip 2026-04-12 00:52:06 +08:00
MaysWind fb35756601 click the translation progress badge to navigate to the list of untranslated entries 2026-04-12 00:46:03 +08:00
MaysWind 721384b9cc support filtering transaction description using regular expressions in insights explorer 2026-04-12 00:40:33 +08:00
MaysWind f2b633cc7b update the filter type titles displayed in the UI 2026-04-11 23:40:00 +08:00
MaysWind 448fc760c0 support not in options for transaction type, transaction category and account filters 2026-04-11 23:38:49 +08:00
MaysWind a604737c7c support credit card billing cycles as a time granularity option in the account balance trend chart on the account reconciliation statements page 2026-04-10 02:32:40 +08:00
MaysWind d44798bf0f use base context to handle the cases where the user IP address is unavailable 2026-04-09 23:44:53 +08:00
MaysWind fcedb3147d display year-over-year and period-over-period growth rates in account balance trends chart in account reconciliation statements on desktop version 2026-04-09 01:04:15 +08:00
MaysWind cd59c4e6a5 fix cannot change explorer display order when fewer than two items are visible on the insights explorer page 2026-04-07 00:01:59 +08:00
MaysWind fe0187ac2c scroll page to bottom when creating a new tag 2026-04-06 23:56:06 +08:00
MaysWind b4c31fc9d0 improve action button rendering performance on desktop version (#547) 2026-04-06 23:31:49 +08:00
MaysWind ae7ee274d5 upgrade third party dependencies 2026-04-01 00:32:45 +08:00
MaysWind ee45d89730 delete all files before updating the translation progress files 2026-03-31 00:58:37 +08:00
MaysWind 5359e3c1fb update the translation progress calculation method 2026-03-31 00:57:46 +08:00
MaysWind 97fb73ad43 add translation process badge 2026-03-30 00:40:14 +08:00
MaysWind ce0c9ec65e add new contributor 2026-03-28 17:34:42 +08:00
MaysWind ed084e1ce0 update README.md 2026-03-28 17:33:53 +08:00
1270o1 ec84065f73 Update DE translation (#540)
Big improvement to the German translation (frontend)
2026-03-28 17:26:09 +08:00
MaysWind 2e8aedcfa6 bump version to 1.5.0 2026-03-22 23:34:42 +08:00
184 changed files with 14555 additions and 7129 deletions
+4 -4
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
@@ -24,10 +24,10 @@ jobs:
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Set up the environment
id: setup
+4 -4
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
@@ -24,10 +24,10 @@ jobs:
type=sha,format=short,prefix=SNAPSHOT-
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Set up the environment
id: setup
@@ -3,81 +3,78 @@ name: Build docker image and package for linux
inputs:
release-build:
required: false
type: string
description: "Whether to build the linux package in release mode. If set to '1', the package will be built in release mode. Otherwise, it will be built in development mode."
build-unix-time:
required: false
type: string
description: "The unix time to use for building the linux package. The value should be a string representing the unix time in seconds."
build-date:
required: false
type: string
description: "The date to use for building the linux package. The value should be a string representing the date in the format of 'YYYYMMDD'."
check-3rd-api:
required: false
type: string
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
skip-tests:
required: false
type: string
description: "Whether to skip tests when building the linux package. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
platform:
required: true
type: string
description: "The platform to build the linux package for. The value should be in the format of 'os/arch[/variant]'. For example, 'linux/amd64', 'linux/arm64/v8', 'linux/arm/v7', or 'linux/arm/v6'."
platform-name:
required: true
type: string
description: "The name of the platform to build the linux package for. The value should be a string that can be used in file names. For example, 'linux-amd64', 'linux-arm64', 'linux-armv7', or 'linux-armv6'."
docker-push:
required: true
type: boolean
description: "Whether to push the built docker image to the registry. If set to 'true', the image will be pushed. Otherwise, it will not be pushed."
docker-image-name:
required: true
type: string
required: false
description: "The repository name of the docker image to build. This is required if 'docker-push' is set to 'true'."
docker-username:
required: false
type: string
description: "Username for logging in to the docker registry. This is required if 'docker-push' is set to 'true'."
docker-password:
required: false
type: string
description: "Password for logging in to the docker registry. This is required if 'docker-push' is set to 'true'."
docker-bake-meta-file-path:
required: true
type: string
description: "The file path to the docker bake meta file."
docker-bake-meta-artifact-name:
required: true
type: string
description: "The name of the artifact that contains the docker bake meta file."
docker-bake-digests-file-path:
required: true
type: string
description: "The file path to save the docker bake digests file. The file will be created with the name of the digest under this path."
docker-bake-digests-artifact-name-prefix:
required: true
type: string
description: "The prefix for the docker bake digests artifact name."
package-file-name-prefix:
required: true
type: string
package-artifact-name-prefix:
required: true
type: string
description: "The prefix for the linux package file name."
runs:
using: "composite"
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Download docker bake meta artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: ${{ inputs.docker-bake-meta-artifact-name }}
path: ${{ runner.temp }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
if: ${{ inputs.docker-username != '' && inputs.docker-password != '' }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ inputs.docker-username }}
password: ${{ inputs.docker-password }}
- name: Build docker for ${{ inputs.platform-name }}
id: bake
uses: docker/bake-action@v6
uses: docker/bake-action@v7
with:
files: |
./docker-bake.hcl
@@ -110,15 +107,15 @@ runs:
tar -czf ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz *
- name: Upload ${{ inputs.platform-name }} digests artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-${{ inputs.platform-name }}
path: ${{ inputs.docker-bake-digests-file-path }}/*
if-no-files-found: error
- name: Upload artifact for ${{ inputs.platform-name }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.package-artifact-name-prefix }}-${{ inputs.platform-name }}
archive: false
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz
if-no-files-found: error
@@ -0,0 +1,55 @@
name: Build backend file for macOS
inputs:
go-version:
required: false
description: "The Go version to use for building the macOS backend. The version should be in the format of 'x.y.z'."
default: "1.26.2"
release-build:
required: false
description: "Whether to build the macOS backend in release mode. If set to '1', the backend will be built in release mode. Otherwise, it will be built in development mode."
build-unix-time:
required: false
description: "The unix time to use for building the macOS backend. The value should be a string representing the unix time in seconds."
build-date:
required: false
description: "The date to use for building the macOS backend. The value should be a string representing the date in the format of 'YYYYMMDD'."
check-3rd-api:
required: false
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
skip-tests:
required: false
description: "Whether to skip tests when building the macOS backend. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
architecture:
required: true
description: "The name of the architecture to build the macOS package for."
backend-artifact-name-prefix:
required: true
description: "The prefix for the macOS backend artifact name."
runs:
using: "composite"
steps:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ inputs.go-version }}
- name: Build backend for macOS-${{ inputs.architecture }}
shell: bash
env:
RELEASE_BUILD: "${{ inputs.release-build }}"
BUILD_PIPELINE: "1"
BUILD_UNIXTIME: "${{ inputs.build-unix-time }}"
BUILD_DATE: "${{ inputs.build-date }}"
CHECK_3RD_API: "${{ inputs.check-3rd-api }}"
SKIP_TESTS: "${{ inputs.skip-tests }}"
run: |
./build.sh backend
- name: Upload macOS-${{ inputs.architecture }} backend artifact
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.backend-artifact-name-prefix }}-macos-${{ inputs.architecture }}
path: ezbookkeeping
if-no-files-found: error
@@ -0,0 +1,58 @@
name: Build packages for macOS
inputs:
architecture:
required: true
description: "The name of the architecture to build the macOS package for."
package-file-name-prefix:
required: true
description: "The prefix for the macOS package file name."
backend-artifact-name-prefix:
required: true
description: "The prefix for the macOS backend artifact name."
runs:
using: "composite"
steps:
- name: Download macOS-${{ inputs.architecture }} backend file
uses: actions/download-artifact@v8
with:
name: ${{ inputs.backend-artifact-name-prefix }}-macos-${{ inputs.architecture }}
path: ${{ runner.temp }}/backend
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz
path: ${{ runner.temp }}/package
- name: Extract frontend files from linux-amd64 package
shell: bash
run: |
mkdir -p package
tar -xzf ${{ runner.temp }}/package/${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz -C package
- name: Package macOS-${{ inputs.architecture }} package
shell: bash
run: |
mkdir -p ezbookkeeping
mkdir -p ezbookkeeping/data
mkdir -p ezbookkeeping/storage
mkdir -p ezbookkeeping/log
cp ${{ runner.temp }}/backend/ezbookkeeping ezbookkeeping/
cp -R package/public ezbookkeeping/public
cp -R conf ezbookkeeping/conf
cp -R templates ezbookkeeping/templates
cp LICENSE ezbookkeeping/
cd ezbookkeeping
chmod +x ezbookkeeping
tar -czf ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-macos-${{ inputs.architecture }}.tar.gz *
cd ..
rm -rf ezbookkeeping
- name: Upload macOS-${{ inputs.architecture }} artifact
uses: actions/upload-artifact@v7
with:
archive: false
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-macos-${{ inputs.architecture }}.tar.gz
if-no-files-found: error
@@ -3,31 +3,34 @@ name: Build backend file for windows
inputs:
go-version:
required: false
default: "1.25.7"
description: "The Go version to use for building the windows backend. The version should be in the format of 'x.y.z'."
default: "1.26.2"
mingw-version:
required: false
description: "The MinGW version to use for building the windows backend. The version should be in the format of 'x.y.z'."
default: "15.2.0"
mingw-revison:
required: false
description: "The MinGW revision to use for building the windows backend. The revision should be in the format of 'vX-revY'."
default: "v13-rev1"
release-build:
required: false
type: string
description: "Whether to build the windows backend in release mode. If set to '1', the backend will be built in release mode. Otherwise, it will be built in development mode."
build-unix-time:
required: false
type: string
description: "The unix time to use for building the windows backend. The value should be a string representing the unix time in seconds."
build-date:
required: false
type: string
description: "The date to use for building the windows backend. The value should be a string representing the date in the format of 'YYYYMMDD'."
check-3rd-api:
required: false
type: string
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
skip-tests:
required: false
type: string
description: "Whether to skip tests when building the windows backend. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
backend-artifact-name-prefix:
required: true
type: string
description: "The prefix for the windows backend artifact name."
runs:
using: "composite"
@@ -69,7 +72,7 @@ runs:
.\build.ps1 backend
- name: Upload windows backend artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
path: ezbookkeeping.exe
@@ -3,27 +3,24 @@ name: Build packages for windows
inputs:
package-file-name-prefix:
required: true
type: string
package-artifact-name-prefix:
required: true
type: string
description: "The prefix for the windows package file name."
backend-artifact-name-prefix:
required: true
type: string
description: "The prefix for the windows backend artifact name."
runs:
using: "composite"
steps:
- name: Download windows-x64 backend file
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
path: ${{ runner.temp }}\backend
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package-artifact-name-prefix }}-linux-amd64
name: ${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz
path: ${{ runner.temp }}\package
- name: Extract frontend files from linux-amd64 package
@@ -50,8 +47,8 @@ runs:
Remove-Item -Recurse -Force ezbookkeeping
- name: Upload windows artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.package-artifact-name-prefix }}-windows-x64
archive: false
path: ${{ inputs.package-file-name-prefix }}-windows-x64.zip
if-no-files-found: error
+12 -12
View File
@@ -3,50 +3,50 @@ name: Push linux docker multi-arch image to registry
inputs:
docker-image-name:
required: true
type: string
description: "The repository name of the docker image to build."
docker-username:
required: true
type: string
description: "Username for logging in to the docker registry."
docker-password:
required: true
type: string
description: "Password for logging in to the docker registry."
docker-bake-meta-file-path:
required: true
type: string
description: "The file path to the docker bake meta file."
docker-bake-meta-artifact-name:
required: true
type: string
description: "The name of the artifact that contains the docker bake meta file."
docker-bake-digests-file-path:
required: true
type: string
description: "The file path to save the docker bake digests file. The file will be created with the name of the digest under this path."
docker-bake-digests-artifact-name-prefix:
required: true
type: string
description: "The prefix for the docker bake digests artifact name."
docker-image-tags:
required: true
type: string
description: "The tags for the docker image to push."
runs:
using: "composite"
steps:
- name: Download docker bake meta artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: ${{ inputs.docker-bake-meta-artifact-name }}
path: ${{ runner.temp }}
- name: Download digests artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-*
merge-multiple: true
path: ${{ inputs.docker-bake-digests-file-path }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ inputs.docker-username }}
password: ${{ inputs.docker-password }}
+352
View File
@@ -0,0 +1,352 @@
const fs = require('fs');
const path = require('path');
const FRONTEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'src', 'locales');
const BACKEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'pkg', 'locales');
const OUTPUT_DIR = process.argv[2] || path.join(__dirname, '..', '..', 'i18n-badge');
const DEFAULT_LANGUAGE_TAG = 'en';
const BACKEND_SKIP_STRUCTS = new Set([
'GlobalTextItems',
'DefaultTypes',
'DataConverterTextItems',
]);
function discoverFrontendLanguages() {
const indexPath = path.join(FRONTEND_LOCALES_DIR, 'index.ts');
const content = fs.readFileSync(indexPath, 'utf-8');
const importMap = {};
const importRegex = /import\s+(\w+)\s+from\s+['"]\.\/([\w_]+\.json)['"]/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
importMap[match[1]] = match[2];
}
const result = {};
const langRegex = /['"]([^'"]+)['"]\s*:\s*\{[^}]*content\s*:\s*(\w+)/g;
while ((match = langRegex.exec(content)) !== null) {
const tag = match[1];
const varName = match[2];
if (importMap[varName]) {
result[tag] = importMap[varName];
}
}
return result;
}
function discoverBackendLanguages() {
const allLocalesPath = path.join(BACKEND_LOCALES_DIR, 'all_locales.go');
const content = fs.readFileSync(allLocalesPath, 'utf-8');
const result = {};
const entryRegex = /"([^"]+)"\s*:\s*\{[^}]*Content\s*:\s*(\w+)/g;
let match;
while ((match = entryRegex.exec(content)) !== null) {
const tag = match[1];
const fileName = tag.toLowerCase().replace(/-/g, '_') + '.go';
const filePath = path.join(BACKEND_LOCALES_DIR, fileName);
if (fs.existsSync(filePath)) {
result[tag] = fileName;
}
}
return result;
}
function flattenJSON(obj, prefix) {
const result = {};
for (const key of Object.keys(obj)) {
const fullKey = prefix ? prefix + '.' + key : key;
if (typeof obj[key] === 'object' && obj[key] !== null) {
Object.assign(result, flattenJSON(obj[key], fullKey));
} else {
result[fullKey] = obj[key];
}
}
return result;
}
function shouldSkipFrontendKey(key) {
if (key.startsWith('global.')) {
return true;
} else if (key.startsWith('default.')) {
return true;
} else if (key.startsWith('currency.')) {
if (key.startsWith('currency.unit.')) {
return true;
} else {
return false;
}
} else if (key.startsWith('mapprovider.')) {
return true;
} else if (key.startsWith('encoding.')) {
return true;
} else if (key.startsWith('document.')) {
if (key.startsWith('document.anchor.')) {
return true;
} else {
return false;
}
} else {
return false;
}
}
function isFrontendAlwaysTranslatedKey(key) {
if (key.startsWith('language.')) {
return true;
} else if (key.startsWith('format.')) {
if (key.startsWith('format.misc.')) {
if (key === 'format.misc.multiTextJoinSeparator') {
return true;
} else if (key === 'format.misc.eachMonthDayInMonthDays') {
return true;
} else {
return false;
}
} else {
return true;
}
} else if (key.startsWith('datetime.')) {
return true;
} else if (key.startsWith('timezone.')) {
return true;
} else if (key.startsWith('currency.')) {
if (key === 'currency.name.EUR') {
return true;
} else {
return false;
}
} else if (key.startsWith('parameter.')) {
if (key === 'parameter.id') {
return true;
} else {
return false;
}
} else {
if (key === 'OK') {
return true;
} else {
return false;
}
}
}
function extractGoStringFields(content) {
const fields = [];
const structBlockRegex = /(\w+):\s*&\w+\{([^}]*)\}/gs;
let blockMatch;
while ((blockMatch = structBlockRegex.exec(content)) !== null) {
const structName = blockMatch[1];
const blockBody = blockMatch[2];
const fieldRegex = /(\w+):\s+"((?:[^"\\]|\\.)*)"/g;
let fieldMatch;
while ((fieldMatch = fieldRegex.exec(blockBody)) !== null) {
fields.push({
struct: structName,
name: fieldMatch[1],
value: fieldMatch[2],
});
}
}
return fields;
}
function getProgressColor(progress) {
if (progress >= 95) {
return 'brightgreen';
} else if (progress >= 90) {
return 'green';
} else if (progress >= 70) {
return 'yellowgreen';
} else if (progress >= 50) {
return 'yellow';
} else if (progress >= 20) {
return 'orange';
} else {
return 'red';
}
}
function main() {
const frontendLangs = discoverFrontendLanguages();
const backendLangs = discoverBackendLanguages();
const allTags = new Set([...Object.keys(frontendLangs), ...Object.keys(backendLangs)]);
console.log('Discovered ' + allTags.size + ' languages: ' + [...allTags].sort().join(', '));
const defaultFrontendJSON = JSON.parse(fs.readFileSync(path.join(FRONTEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.json`), 'utf-8'));
const defaultFrontendItemsMap = flattenJSON(defaultFrontendJSON, '');
const defaultFrontendKeys = Object.keys(defaultFrontendItemsMap);
const frontendTranslatableKeys = defaultFrontendKeys.filter(function (k) {
return !shouldSkipFrontendKey(k);
});
const frontendSkippedCount = defaultFrontendKeys.length - frontendTranslatableKeys.length;
const frontendTotal = frontendTranslatableKeys.length;
const defaultBackendContent = fs.readFileSync(path.join(BACKEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.go`), 'utf-8');
const defaultBackendItems = extractGoStringFields(defaultBackendContent);
const defaultBackendTranslatableItems = defaultBackendItems.filter(function (f) {
return !BACKEND_SKIP_STRUCTS.has(f.struct);
});
const backendSkippedCount = defaultBackendItems.length - defaultBackendTranslatableItems.length;
const backendTotal = defaultBackendTranslatableItems.length;
console.log('Frontend: ' + frontendTotal + ' translatable keys (' + frontendSkippedCount + ' excluded)');
console.log('Backend: ' + backendTotal + ' translatable fields (' + backendSkippedCount + ' excluded)');
const results = {};
const untranslatedKeys = {};
for (const tag of allTags) {
results[tag] = {
languageTag: tag,
frontendTranslated: 0,
frontendTotal: frontendTotal,
backendTranslated: 0,
backendTotal: backendTotal
};
untranslatedKeys[tag] = [];
}
for (const tag of Object.keys(frontendLangs)) {
if (tag === DEFAULT_LANGUAGE_TAG) {
results[tag].frontendTranslated = frontendTotal;
continue;
}
const file = frontendLangs[tag];
const filePath = path.join(FRONTEND_LOCALES_DIR, file);
if (!fs.existsSync(filePath)) {
continue;
}
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
const kv = flattenJSON(json, '');
let translated = 0;
for (const key of frontendTranslatableKeys) {
if (kv[key] !== undefined && kv[key] !== '' && (kv[key] !== defaultFrontendItemsMap[key] || isFrontendAlwaysTranslatedKey(key))) {
translated++;
} else {
untranslatedKeys[tag].push({ source: path.join('src', 'locales', file), key: key, defaultValue: defaultFrontendItemsMap[key], value: kv[key] });
}
}
results[tag].frontendTranslated = translated;
}
for (const tag of Object.keys(backendLangs)) {
if (tag === DEFAULT_LANGUAGE_TAG) {
results[tag].backendTranslated = backendTotal;
continue;
}
const file = backendLangs[tag];
const filePath = path.join(BACKEND_LOCALES_DIR, file);
if (!fs.existsSync(filePath)) {
continue;
}
const content = fs.readFileSync(filePath, 'utf-8');
const fields = extractGoStringFields(content).filter(function (f) {
return !BACKEND_SKIP_STRUCTS.has(f.struct);
});
let translated = 0;
for (let i = 0; i < defaultBackendTranslatableItems.length; i++) {
if (i < fields.length && fields[i].value !== defaultBackendTranslatableItems[i].value) {
translated++;
} else {
untranslatedKeys[tag].push({ source: path.join('pkg', 'locales', file), key: defaultBackendTranslatableItems[i].struct + '.' + defaultBackendTranslatableItems[i].name, defaultValue: defaultBackendTranslatableItems[i].value, value: (i < fields.length) ? fields[i].value : null });
}
}
results[tag].backendTranslated = translated;
}
for (const tag of Object.keys(results)) {
const r = results[tag];
const totalTranslated = r.frontendTranslated + r.backendTranslated;
const totalItems = r.frontendTotal + r.backendTotal;
r.totalProgress = Math.round((totalTranslated / totalItems) * 10000) / 100;
}
const sortedResults = {};
var sortedTags = Object.keys(results).sort();
for (const tag of sortedTags) {
sortedResults[tag] = results[tag];
}
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
var badgesDir = path.join(OUTPUT_DIR, 'badges');
if (!fs.existsSync(badgesDir)) {
fs.mkdirSync(badgesDir, { recursive: true });
}
fs.writeFileSync(
path.join(OUTPUT_DIR, 'i18n-progress.json'),
JSON.stringify(sortedResults, null, 4) + '\n'
);
for (const tag of sortedTags) {
const data = sortedResults[tag];
const badge = {
schemaVersion: 1,
label: 'translation',
message: data.totalProgress + '%',
color: getProgressColor(data.totalProgress)
};
fs.writeFileSync(
path.join(badgesDir, tag + '.json'),
JSON.stringify(badge, null, 4) + '\n'
);
}
var untranslatedDir = path.join(OUTPUT_DIR, 'untranslated');
if (!fs.existsSync(untranslatedDir)) {
fs.mkdirSync(untranslatedDir, { recursive: true });
}
for (const tag of sortedTags) {
const items = untranslatedKeys[tag] || [];
fs.writeFileSync(
path.join(untranslatedDir, tag + '.json'),
JSON.stringify(items, null, 4) + '\n'
);
}
for (const tag of sortedTags) {
const data = sortedResults[tag];
const missingCount = (untranslatedKeys[tag] || []).length;
console.log(tag + ': ' + data.totalProgress + '% (frontend: ' + data.frontendTranslated + '/' + data.frontendTotal + ', backend: ' + data.backendTranslated + '/' + data.backendTotal + ', untranslated: ' + missingCount + ')');
}
console.log('\nResults written to ' + OUTPUT_DIR);
}
main();
+40 -12
View File
@@ -18,15 +18,14 @@ jobs:
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ vars.DOCKER_IMAGE_NAME }}
@@ -43,7 +42,6 @@ jobs:
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
- name: Rename docker bake meta file
@@ -51,7 +49,7 @@ jobs:
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
- name: Upload docker bake meta artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
@@ -63,7 +61,7 @@ jobs:
- setup
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-linux-docker-and-package
with:
@@ -80,7 +78,6 @@ jobs:
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
build-linux-docker-and-package-arm:
runs-on: ubuntu-24.04-arm
@@ -97,7 +94,7 @@ jobs:
platform-name: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-linux-docker-and-package
with:
@@ -114,7 +111,6 @@ jobs:
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
build-windows-backend:
needs:
@@ -122,7 +118,7 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-windows-backend
with:
@@ -132,6 +128,23 @@ jobs:
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-macos-backend:
needs:
- setup
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: ./.github/actions/build-macos-backend
with:
architecture: arm64
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-windows-package:
needs:
- setup
@@ -140,10 +153,25 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-windows-package
with:
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-macos-package:
needs:
- setup
- build-macos-backend
- build-linux-docker-and-package-x86
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: ./.github/actions/build-macos-package
with:
architecture: arm64
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
+52 -18
View File
@@ -9,6 +9,7 @@ jobs:
setup:
runs-on: ubuntu-latest
outputs:
docker-push: ${{ steps.variable.outputs.docker_push }}
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
build-date: ${{ steps.variable.outputs.build_date }}
docker-version: ${{ steps.meta.outputs.version }}
@@ -19,15 +20,14 @@ jobs:
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ vars.DOCKER_IMAGE_NAME }}
@@ -39,6 +39,7 @@ jobs:
- name: Set up variables
id: variable
run: |
echo "docker_push=${{ vars.DOCKER_IMAGE_NAME != '' && vars.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }}" >> "$GITHUB_OUTPUT"
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
@@ -46,7 +47,6 @@ jobs:
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-release-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-release-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-release-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
- name: Rename docker bake meta file
@@ -54,7 +54,7 @@ jobs:
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
- name: Upload docker bake meta artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
@@ -66,7 +66,7 @@ jobs:
- setup
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-linux-docker-and-package
with:
@@ -77,7 +77,7 @@ jobs:
skip-tests: ${{ vars.SKIP_TESTS }}
platform: linux/amd64
platform-name: linux-amd64
docker-push: true
docker-push: ${{ needs.setup.outputs.docker-push }}
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
@@ -86,7 +86,6 @@ jobs:
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
build-linux-docker-and-package-arm:
runs-on: ubuntu-24.04-arm
@@ -103,7 +102,7 @@ jobs:
platform-name: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-linux-docker-and-package
with:
@@ -114,7 +113,7 @@ jobs:
skip-tests: ${{ vars.SKIP_TESTS }}
platform: ${{ matrix.platform }}
platform-name: ${{ matrix.platform-name }}
docker-push: true
docker-push: ${{ needs.setup.outputs.docker-push }}
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
@@ -123,9 +122,9 @@ jobs:
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
push-linux-docker:
if: ${{ needs.setup.outputs.docker-push == 'true' }}
needs:
- setup
- build-linux-docker-and-package-x86
@@ -133,7 +132,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/push-linux-docker
with:
@@ -152,7 +151,7 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-windows-backend
with:
@@ -163,6 +162,24 @@ jobs:
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-macos-backend:
needs:
- setup
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: ./.github/actions/build-macos-backend
with:
architecture: arm64
release-build: 1
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-windows-package:
needs:
- setup
@@ -171,12 +188,27 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-windows-package
with:
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-macos-package:
needs:
- setup
- build-macos-backend
- build-linux-docker-and-package-x86
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: ./.github/actions/build-macos-package
with:
architecture: arm64
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
publish-release:
@@ -186,17 +218,19 @@ jobs:
- build-linux-docker-and-package-x86
- build-linux-docker-and-package-arm
- build-windows-package
- build-macos-package
- push-linux-docker
steps:
- name: Download all packaged files
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}-*
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}-*
merge-multiple: true
skip-decompress: true
path: release-files
- name: Publish Release ${{ github.ref_name }}
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
+46 -15
View File
@@ -9,6 +9,7 @@ jobs:
setup:
runs-on: ubuntu-latest
outputs:
docker-push: ${{ steps.variable.outputs.docker_push }}
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
build-date: ${{ steps.variable.outputs.build_date }}
docker-version: ${{ steps.meta.outputs.version }}
@@ -19,15 +20,14 @@ jobs:
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ vars.DOCKER_IMAGE_NAME }}
@@ -39,6 +39,7 @@ jobs:
- name: Set up variables
id: variable
run: |
echo "docker_push=${{ vars.DOCKER_IMAGE_NAME != '' && vars.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }}" >> "$GITHUB_OUTPUT"
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
@@ -46,7 +47,6 @@ jobs:
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
- name: Rename docker bake meta file
@@ -54,7 +54,7 @@ jobs:
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
- name: Upload docker bake meta artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
@@ -66,7 +66,7 @@ jobs:
- setup
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-linux-docker-and-package
with:
@@ -76,7 +76,7 @@ jobs:
skip-tests: ${{ vars.SKIP_TESTS }}
platform: linux/amd64
platform-name: linux-amd64
docker-push: true
docker-push: ${{ needs.setup.outputs.docker-push }}
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
@@ -85,7 +85,6 @@ jobs:
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
build-linux-docker-and-package-arm:
runs-on: ubuntu-24.04-arm
@@ -102,7 +101,7 @@ jobs:
platform-name: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-linux-docker-and-package
with:
@@ -112,7 +111,7 @@ jobs:
skip-tests: ${{ vars.SKIP_TESTS }}
platform: ${{ matrix.platform }}
platform-name: ${{ matrix.platform-name }}
docker-push: true
docker-push: ${{ needs.setup.outputs.docker-push }}
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
@@ -121,9 +120,9 @@ jobs:
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
push-linux-docker:
if: ${{ needs.setup.outputs.docker-push == 'true' }}
needs:
- setup
- build-linux-docker-and-package-x86
@@ -131,7 +130,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/push-linux-docker
with:
@@ -150,7 +149,7 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-windows-backend
with:
@@ -160,6 +159,23 @@ jobs:
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-macos-backend:
needs:
- setup
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: ./.github/actions/build-macos-backend
with:
architecture: arm64
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-windows-package:
needs:
- setup
@@ -168,10 +184,25 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-windows-package
with:
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-macos-package:
needs:
- setup
- build-macos-backend
- build-linux-docker-and-package-x86
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: ./.github/actions/build-macos-package
with:
architecture: arm64
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
@@ -0,0 +1,76 @@
name: Update i18n Translation Progress Badges
on:
push:
branches:
- main
paths:
- 'src/locales/**'
- 'pkg/locales/**'
workflow_dispatch:
jobs:
update-i18n-progress:
if: ${{ vars.UPDATE_I18N_BADGE_REPO == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Update translation progress data
run: |
node .github/scripts/update-i18n-progress.js ${{ runner.temp }}/i18n-badge
- name: Checkout badge repository
uses: actions/checkout@v6
with:
repository: mayswind/ezbookkeeping-i18n-badge
token: ${{ secrets.I18N_BADGE_REPO_TOKEN }}
path: ezbookkeeping-i18n-badge
- name: Update badge data
run: |
rm -rf ezbookkeeping-i18n-badge/i18n-progress.json
cp ${{ runner.temp }}/i18n-badge/i18n-progress.json ezbookkeeping-i18n-badge/
mkdir -p ezbookkeeping-i18n-badge/badges
rm -rf ezbookkeeping-i18n-badge/badges/*
cp ${{ runner.temp }}/i18n-badge/badges/*.json ezbookkeeping-i18n-badge/badges/
mkdir -p ezbookkeeping-i18n-badge/untranslated
rm -rf ezbookkeeping-i18n-badge/untranslated/*
cp ${{ runner.temp }}/i18n-badge/untranslated/*.json ezbookkeeping-i18n-badge/untranslated/
- name: Commit and push
run: |
cd ezbookkeeping-i18n-badge
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "Update i18n progress data (${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})"
git push
fi
- name: Purge GitHub camo image cache
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CAMO_URLS=$(curl -s -H "Accept: application/vnd.github.html+json" -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/${{ github.repository }}/readme" | grep -oP 'https://camo\.githubusercontent\.com/[^"]+' | sort -u)
if [ -z "$CAMO_URLS" ]; then
echo "No camo URLs found, skipping cache purge"
exit 0
fi
for url in $CAMO_URLS; do
echo "Purging: $url"
curl -s -X PURGE "$url" > /dev/null
done
echo "Purged $(echo "$CAMO_URLS" | wc -l) camo URLs"
+4 -4
View File
@@ -151,13 +151,13 @@ dist/
# Binary and build files
ezbookkeeping
!**/ezbookkeeping/
package/
/package/
# Environment variable files
.env
**/.env
# Other directories
data/
storage/
log/
/data/
/storage/
/log/
+3 -3
View File
@@ -1,5 +1,5 @@
# Build backend binary file
FROM golang:1.25.7-alpine3.23 AS be-builder
FROM golang:1.26.2-alpine3.23 AS be-builder
ARG RELEASE_BUILD
ARG BUILD_PIPELINE
ARG BUILD_UNIXTIME
@@ -19,7 +19,7 @@ RUN apk add git gcc g++ libc-dev
RUN ./build.sh backend
# Build frontend files
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine3.23 AS fe-builder
FROM --platform=$BUILDPLATFORM node:24.15.0-alpine3.23 AS fe-builder
ARG RELEASE_BUILD
ARG BUILD_PIPELINE
ARG BUILD_UNIXTIME
@@ -35,7 +35,7 @@ RUN apk add git
RUN ./build.sh frontend
# Package docker image
FROM alpine:3.23.3
FROM alpine:3.23.4
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
+23 -23
View File
@@ -34,7 +34,7 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
- **AI-Powered Features**
- Receipt image recognition
- MCP (Model Context Protocol) support for AI integration
- API command-line script tools for AI integration
- Agent Skill and API command-line script tools support for AI integration
- **Powerful Bookkeeping**
- Two-level accounts and categories
- Image attachments for transactions
@@ -54,7 +54,7 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
- **Data Import & Export**
- Supports CSV, OFX, QFX, QIF, IIF, Camt.052, Camt.053, MT940, GnuCash, Firefly III, Beancount and more
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/comparison/).
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/features/).
## Screenshots
### Desktop Version
@@ -129,27 +129,27 @@ Help make ezBookkeeping accessible to users around the world. We welcome help to
Currently available translations:
| Tag | Language | Contributors |
| --- | --- | --- |
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
| en | English | / |
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
| it | Italiano | [@waron97](https://github.com/waron97) |
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) |
| ko | 한국어 | [@overworks](https://github.com/overworks) |
| nl | Nederlands | [@automagics](https://github.com/automagics) |
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
| ru | Русский | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) |
| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) |
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) |
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
| zh-Hans | 中文 (简体) | / |
| zh-Hant | 中文 (繁體) | / |
| Tag | Language | Progress | Contributors |
| --- | --- | --- | --- |
| de | Deutsch | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fde.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/de.json) | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) |
| en | English | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fen.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/en.json) | / |
| es | Español | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fes.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/es.json) | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
| fr | Français | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Ffr.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/fr.json) | [@brieucdlf](https://github.com/brieucdlf) |
| it | Italiano | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fit.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/it.json) | [@waron97](https://github.com/waron97) |
| ja | 日本語 | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fja.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ja.json) | [@tkymmm](https://github.com/tkymmm) |
| kn | ಕನ್ನಡ | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fkn.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/kn.json) | [@Darshanbm05](https://github.com/Darshanbm05) |
| ko | 한국어 | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fko.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ko.json) | [@overworks](https://github.com/overworks) |
| nl | Nederlands | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fnl.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/nl.json) | [@automagics](https://github.com/automagics) |
| pt-BR | Português (Brasil) | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fpt-BR.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/pt-BR.json) | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
| ru | Русский | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fru.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ru.json) | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
| sl | Slovenščina | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fsl.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/sl.json) | [@thehijacker](https://github.com/thehijacker) |
| ta | தமிழ் | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fta.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ta.json) | [@hhharsha36](https://github.com/hhharsha36) |
| th | ไทย | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fth.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/th.json) | [@natthavat28](https://github.com/natthavat28) |
| tr | Türkçe | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Ftr.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/tr.json) | [@aydnykn](https://github.com/aydnykn) |
| uk | Українська | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fuk.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/uk.json) | [@nktlitvinenko](https://github.com/nktlitvinenko) |
| vi | Tiếng Việt | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fvi.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/vi.json) | [@f97](https://github.com/f97) |
| zh-Hans | 中文 (简体) | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fzh-Hans.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/zh-Hans.json) | / |
| zh-Hant | 中文 (繁體) | [![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fzh-Hant.json)](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/zh-Hant.json) | / |
## Documentation
1. [English](https://ezbookkeeping.mayswind.net)
+7 -1
View File
@@ -160,6 +160,12 @@ build_backend() {
fi
fi
ld_static_link_flags=""
if [ "$(uname -s)" = "Linux" ]; then
ld_static_link_flags="-linkmode external -extldflags '-static'"
fi
backend_build_extra_arguments="-X main.Version=$VERSION"
backend_build_extra_arguments="$backend_build_extra_arguments -X main.CommitHash=$COMMIT_HASH"
@@ -169,7 +175,7 @@ build_backend() {
echo "Building backend binary file ($RELEASE_TYPE)..."
CGO_ENABLED=1 go build -a -v -trimpath -ldflags "-w -s -linkmode external -extldflags '-static' $backend_build_extra_arguments" -o ezbookkeeping ezbookkeeping.go
CGO_ENABLED=1 go build -a -v -trimpath -ldflags "-w -s $ld_static_link_flags $backend_build_extra_arguments" -o ezbookkeeping ezbookkeeping.go
chmod +x ezbookkeeping
}
+1
View File
@@ -953,6 +953,7 @@ func printUserInfo(user *models.User) {
fmt.Printf("[Password] %s\n", user.Password)
fmt.Printf("[Salt] %s\n", user.Salt)
fmt.Printf("[DefaultAccountId] %d\n", user.DefaultAccountId)
fmt.Printf("[UseLastReconciledTime] %t\n", user.UseLastReconciledTime)
fmt.Printf("[TransactionEditScope] %s (%d)\n", user.TransactionEditScope, user.TransactionEditScope)
fmt.Printf("[Language] %s\n", user.Language)
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
+7
View File
@@ -375,6 +375,7 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.GET("/accounts/get.json", bindApi(api.Accounts.AccountGetHandler))
apiV1Route.POST("/accounts/add.json", bindApi(api.Accounts.AccountCreateHandler))
apiV1Route.POST("/accounts/modify.json", bindApi(api.Accounts.AccountModifyHandler))
apiV1Route.POST("/accounts/update/last_reconciled_time.json", bindApi(api.Accounts.AccountUpdateLastReconciledTimeHandler))
apiV1Route.POST("/accounts/hide.json", bindApi(api.Accounts.AccountHideHandler))
apiV1Route.POST("/accounts/move.json", bindApi(api.Accounts.AccountMoveHandler))
apiV1Route.POST("/accounts/delete.json", bindApi(api.Accounts.AccountDeleteHandler))
@@ -393,8 +394,14 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
apiV1Route.POST("/transactions/batch_update/category.json", bindApi(api.Transactions.TransactionBatchUpdateCategoriesHandler))
apiV1Route.POST("/transactions/batch_update/account.json", bindApi(api.Transactions.TransactionBatchUpdateAccountsHandler))
apiV1Route.POST("/transactions/batch_update/tag/add.json", bindApi(api.Transactions.TransactionBatchAddTagsHandler))
apiV1Route.POST("/transactions/batch_update/tag/remove.json", bindApi(api.Transactions.TransactionBatchRemoveTagsHandler))
apiV1Route.POST("/transactions/batch_update/tag/clear.json", bindApi(api.Transactions.TransactionBatchClearTagsHandler))
apiV1Route.POST("/transactions/move/all.json", bindApi(api.Transactions.TransactionMoveAllBetweenAccountsHandler))
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
apiV1Route.POST("/transactions/batch_delete.json", bindApi(api.Transactions.TransactionBatchDeleteHandler))
if config.EnableDataImport {
apiV1Route.POST("/transactions/parse_custom_file.json", bindApi(api.Transactions.TransactionParseImportCustomFileDataHandler))
+1
View File
@@ -536,6 +536,7 @@ custom_map_tile_server_default_zoom_level = 14
# "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/
# "national_bank_of_kazakhstan": https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut
# "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/
+2 -1
View File
@@ -14,7 +14,8 @@
],
"translators": {
"de": [
"chrgm"
"chrgm",
"1270o1"
],
"en": [],
"es": [
+33 -32
View File
@@ -1,33 +1,33 @@
module github.com/mayswind/ezbookkeeping
go 1.25
go 1.26.0
require (
github.com/boombuler/barcode v1.1.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
github.com/gin-contrib/cache v1.4.1
github.com/gin-contrib/gzip v1.2.5
github.com/gin-gonic/gin v1.11.0
github.com/go-co-op/gocron/v2 v2.19.1
github.com/go-playground/validator/v10 v10.30.1
github.com/gin-contrib/cache v1.4.4
github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0
github.com/go-co-op/gocron/v2 v2.21.1
github.com/go-playground/validator/v10 v10.30.2
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/invopop/jsonschema v0.13.0
github.com/lib/pq v1.11.1
github.com/mattn/go-sqlite3 v1.14.33
github.com/minio/minio-go/v7 v7.0.98
github.com/lib/pq v1.12.3
github.com/mattn/go-sqlite3 v1.14.42
github.com/minio/minio-go/v7 v7.0.100
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v3 v3.6.2
github.com/urfave/cli/v3 v3.8.0
github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/xuri/excelize/v2 v2.10.0
golang.org/x/crypto v0.47.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/text v0.33.0
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.36.0
gopkg.in/ini.v1 v1.67.1
gopkg.in/mail.v2 v2.3.1
xorm.io/builder v0.3.13
@@ -40,8 +40,8 @@ require (
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
@@ -52,20 +52,20 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/redigo v1.9.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -80,30 +80,31 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // 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.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
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+62 -63
View File
@@ -14,10 +14,10 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
@@ -28,8 +28,8 @@ github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLI
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -43,34 +43,34 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cache v1.4.4 h1:4Sasrroa8CrbRYQ3aEMutRJGhz7ujyPlKvAPmJdIx9U=
github.com/gin-contrib/cache v1.4.4/go.mod h1:OfwzOu0CcBcQYgvc+wg7moQWFzmJCKqmo0NU7Wx3xyQ=
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-co-op/gocron/v2 v2.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnAmgbU=
github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
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-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -90,8 +90,8 @@ github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7X
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.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -104,22 +104,22 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
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/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
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.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
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=
@@ -139,15 +139,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
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=
@@ -167,58 +166,58 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
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/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
-21
View File
@@ -1,21 +0,0 @@
import { type JestConfigWithTsJest, createDefaultEsmPreset } from 'ts-jest';
const presetConfig = createDefaultEsmPreset({
tsconfig: '<rootDir>/tsconfig.jest.json'
});
const config: JestConfigWithTsJest = {
...presetConfig,
clearMocks: true,
collectCoverage: false,
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1"
},
testEnvironment: "node",
testMatch: [
"**/__tests__/**/*.[jt]s?(x)",
"!**/__tests__/*_gen.[jt]s?(x)"
]
};
export default config;
+1776 -3959
View File
File diff suppressed because it is too large Load Diff
+56 -58
View File
@@ -1,6 +1,6 @@
{
"name": "ezbookkeeping",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"repository": {
"type": "git",
@@ -16,67 +16,65 @@
"build": "cross-env NODE_ENV=production vite build",
"serve:dist": "vite preview",
"lint": "vue-tsc --noEmit && eslint . --fix",
"test": "cross-env TS_NODE_PROJECT=\"./tsconfig.jest.json\" jest"
"test": "vitest run"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@vuepic/vue-datepicker": "^12.1.0",
"axios": "^1.13.4",
"cbor-js": "^0.1.0",
"chardet": "^2.1.1",
"clipboard": "^2.0.11",
"crypto-js": "^4.2.0",
"dom7": "^4.0.6",
"echarts": "^6.0.0",
"framework7": "^9.0.3",
"framework7-icons": "^5.0.5",
"framework7-vue": "^9.0.3",
"jalaali-js": "^1.2.8",
"leaflet": "^1.9.4",
"line-awesome": "^1.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"pinia": "^3.0.4",
"register-service-worker": "^1.7.2",
"skeleton-elements": "^4.0.1",
"swiper": "^12.1.0",
"ua-parser-js": "^1.0.39",
"vue": "^3.5.27",
"vue-echarts": "^8.0.1",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.2",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.11.8"
"@mdi/js": "7.4.47",
"@vuepic/vue-datepicker": "12.1.0",
"axios": "1.15.2",
"cbor-js": "0.1.0",
"chardet": "2.1.1",
"clipboard": "2.0.11",
"crypto-js": "4.2.0",
"dom7": "4.0.6",
"echarts": "6.0.0",
"framework7": "9.0.3",
"framework7-icons": "5.0.5",
"framework7-vue": "9.0.3",
"jalaali-js": "1.2.8",
"leaflet": "1.9.4",
"line-awesome": "1.3.0",
"moment": "2.30.1",
"moment-timezone": "0.6.1",
"pinia": "3.0.4",
"register-service-worker": "1.7.2",
"skeleton-elements": "4.0.1",
"swiper": "12.1.3",
"ua-parser-js": "1.0.39",
"vue": "3.5.33",
"vue-echarts": "8.0.1",
"vue-i18n": "11.3.2",
"vue-router": "5.0.6",
"vue3-perfect-scrollbar": "2.0.0",
"vuedraggable": "4.1.0",
"vuetify": "3.12.5"
},
"devDependencies": {
"@jest/globals": "^30.2.0",
"@tsconfig/node24": "^24.0.4",
"@types/cbor-js": "^0.1.1",
"@types/crypto-js": "^4.2.2",
"@types/git-rev-sync": "^2.0.2",
"@types/jalaali-js": "^1.2.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.1.0",
"@types/ua-parser-js": "^0.7.39",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.7.0",
"git-rev-sync": "^3.0.2",
"jest": "^30.2.0",
"postcss-preset-env": "^11.1.3",
"sass": "^1.97.3",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-checker": "^0.12.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-vuetify": "^2.1.3",
"vue-tsc": "^3.2.4"
"@jest/globals": "30.3.0",
"@tsconfig/node24": "24.0.4",
"@types/cbor-js": "0.1.1",
"@types/crypto-js": "4.2.2",
"@types/git-rev-sync": "2.0.2",
"@types/jalaali-js": "1.2.0",
"@types/node": "25.6.0",
"@types/ua-parser-js": "0.7.39",
"@vitejs/plugin-vue": "6.0.6",
"@vue/eslint-config-typescript": "14.7.0",
"@vue/tsconfig": "0.9.1",
"cross-env": "10.1.0",
"eslint": "10.2.1",
"eslint-plugin-vue": "10.9.0",
"git-rev-sync": "3.0.2",
"postcss-preset-env": "11.2.1",
"sass": "1.99.0",
"ts-node": "10.9.2",
"typescript": "6.0.3",
"vite": "7.3.2",
"vite-plugin-checker": "0.13.0",
"vite-plugin-pwa": "1.2.0",
"vite-plugin-vuetify": "2.1.3",
"vitest": "4.1.5",
"vue-tsc": "3.2.7"
},
"browserslist": [
"last 5 Chrome versions",
+104 -13
View File
@@ -19,6 +19,7 @@ type AccountsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
accounts *services.AccountService
users *services.UserService
}
// Initialize an account api singleton instance
@@ -34,6 +35,7 @@ var (
container: duplicatechecker.Container,
},
accounts: services.Accounts,
users: services.Users,
}
)
@@ -333,6 +335,16 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
if err != nil {
@@ -434,7 +446,11 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
var toAddAccountBalanceTimes []int64
var toDeleteAccountIds []int64
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
toUpdateAccount, err := a.getToUpdateAccount(user, &accountModifyReq, mainAccount, false)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if toUpdateAccount != nil {
if toUpdateAccount.Category != mainAccount.Category {
@@ -483,7 +499,11 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, 0)
}
} else {
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
toUpdateSubAccount, err := a.getToUpdateAccount(user, subAccountReq, accountMap[subAccountReq.Id], true)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if toUpdateSubAccount != nil {
anythingUpdate = true
@@ -607,6 +627,69 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
return accountResp, nil
}
// AccountUpdateLastReconciledTimeHandler updates last reconciled time of an existed account by request parameters for current user
func (a *AccountsApi) AccountUpdateLastReconciledTimeHandler(c *core.WebContext) (any, *errs.Error) {
var accountUpdateReq models.AccountUpdateLastReconciledTimeRequest
err := c.ShouldBindJSON(&accountUpdateReq)
if err != nil {
log.Warnf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if accountUpdateReq.Id <= 0 {
return nil, errs.ErrAccountIdInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if !user.UseLastReconciledTime {
return nil, errs.ErrLastReconciledTimeIsNotEnabled
}
account, err := a.accounts.GetAccountByAccountId(c, uid, accountUpdateReq.Id)
if err != nil {
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountUpdateReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
return nil, errs.ErrParentAccountCannotSetLastReconciledTime
}
if account.Extend == nil {
account.Extend = &models.AccountExtend{}
}
if account.Extend.LastReconciledTime != nil && accountUpdateReq.LastReconciledTime < *account.Extend.LastReconciledTime {
return nil, errs.ErrCannotSetLastReconciledTimeBeforeCurrent
} else if account.Extend.LastReconciledTime != nil && accountUpdateReq.LastReconciledTime == *account.Extend.LastReconciledTime {
return nil, errs.ErrNothingWillBeUpdated
}
account.Extend.LastReconciledTime = &accountUpdateReq.LastReconciledTime
err = a.accounts.UpdateAccountExtend(c, uid, account)
if err != nil {
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to update last reconciled time for account \"id:%d\" of user \"uid:%d\", because %s", account.AccountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[accounts.AccountUpdateLastReconciledTimeHandler] user \"uid:%d\" has updated last reconciled time \"%d\" for account \"id:%d\"", uid, account.Extend.LastReconciledTime, account.AccountId)
return true, nil
}
// AccountHideHandler hides an existed account by request parameters for current user
func (a *AccountsApi) AccountHideHandler(c *core.WebContext) (any, *errs.Error) {
var accountHideReq models.AccountHideRequest
@@ -764,8 +847,9 @@ func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models
return childrenAccounts, childrenAccountBalanceTimes
}
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) *models.Account {
func (a *AccountsApi) getToUpdateAccount(user *models.User, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) (*models.Account, error) {
newAccountExtend := &models.AccountExtend{}
newAccountExtend.LastReconciledTime = accountModifyReq.LastReconciledTime
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
@@ -773,7 +857,7 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
newAccount := &models.Account{
AccountId: oldAccount.AccountId,
Uid: uid,
Uid: user.Uid,
Name: accountModifyReq.Name,
DisplayOrder: oldAccount.DisplayOrder,
Category: accountModifyReq.Category,
@@ -790,21 +874,28 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
newAccount.Color != oldAccount.Color ||
newAccount.Comment != oldAccount.Comment ||
newAccount.Hidden != oldAccount.Hidden {
return newAccount
}
if (newAccount.Extend != nil && oldAccount.Extend == nil) ||
(newAccount.Extend == nil && oldAccount.Extend != nil) {
return newAccount
return newAccount, nil
}
oldAccountExtend := oldAccount.Extend
if newAccountExtend.CreditCardStatementDate != oldAccountExtend.CreditCardStatementDate {
return newAccount
if (newAccountExtend.LastReconciledTime != nil && (oldAccountExtend == nil || oldAccountExtend.LastReconciledTime == nil)) ||
(newAccountExtend.LastReconciledTime == nil && oldAccountExtend != nil && oldAccountExtend.LastReconciledTime != nil) ||
(newAccountExtend.LastReconciledTime != nil && oldAccountExtend != nil && oldAccountExtend.LastReconciledTime != nil && *newAccountExtend.LastReconciledTime != *oldAccountExtend.LastReconciledTime) {
if !user.UseLastReconciledTime {
return nil, errs.ErrLastReconciledTimeIsNotEnabled
}
return nil
return newAccount, nil
}
if (newAccountExtend.CreditCardStatementDate != nil && (oldAccountExtend == nil || oldAccountExtend.CreditCardStatementDate == nil)) ||
(newAccountExtend.CreditCardStatementDate == nil && oldAccountExtend != nil && oldAccountExtend.CreditCardStatementDate != nil) ||
(newAccountExtend.CreditCardStatementDate != nil && oldAccountExtend != nil && oldAccountExtend.CreditCardStatementDate != nil && *newAccountExtend.CreditCardStatementDate != *oldAccountExtend.CreditCardStatementDate) {
return newAccount, nil
}
return nil, nil
}
func (a *AccountsApi) getToDeleteSubAccountIds(accountModifyReq *models.AccountModifyRequest, mainAccount *models.Account, accountAndSubAccounts []*models.Account) []int64 {
+1 -1
View File
@@ -419,7 +419,7 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(exportTransactionDataReq.MinTime)
}
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, pageCountForDataExport, true)
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, false, pageCountForDataExport, true)
if err != nil {
log.Errorf(c, "[data_managements.getExportedFileContent] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
+1 -1
View File
@@ -166,7 +166,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any,
Password: request.Password,
}
_, _, err = a.users.UpdateUser(c, userNew, false)
_, _, err = a.users.UpdateUser(c, userNew, false, false)
if err != nil {
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
+787 -16
View File
@@ -25,6 +25,7 @@ import (
)
const pageCountForAccountStatement = 1000
const pageCountForMovingAccountTransactions = 1000
// TransactionsApi represents transaction api
type TransactionsApi struct {
@@ -97,7 +98,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, tagFilters, noTags, transactionCountReq.AmountFilter, transactionCountReq.Keyword)
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionCountReq.AmountFilter, transactionCountReq.Keyword, transactionCountReq.MustHavePictures)
if err != nil {
log.Errorf(c, "[transactions.TransactionCountHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
@@ -168,7 +169,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, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.MustHavePictures)
if err != nil {
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
@@ -176,7 +177,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, tagFilters, 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, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.MustHavePictures, 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())
@@ -276,7 +277,7 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
}
}
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.MustHavePictures)
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())
@@ -371,7 +372,7 @@ func (a *TransactionsApi) TransactionListAllHandler(c *core.WebContext) (any, *e
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(transactionAllListReq.StartTime)
}
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, transactionAllListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionAllListReq.AmountFilter, transactionAllListReq.Keyword, pageCountForDataExport, true)
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, transactionAllListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionAllListReq.AmountFilter, transactionAllListReq.Keyword, transactionAllListReq.MustHavePictures, pageCountForDataExport, true)
if err != nil {
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to get all transactions for user \"uid:%d\", because %s", uid, err.Error())
@@ -1075,7 +1076,15 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
}
transaction := a.createNewTransactionModel(uid, &transactionCreateReq, c.ClientIP())
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, []*models.Transaction{transaction})
if err != nil {
log.Errorf(c, "[transactions.TransactionCreateHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
if !transactionEditable {
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
@@ -1268,8 +1277,15 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return nil, errs.ErrNothingWillBeUpdated
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
newTransactionEditable := user.CanEditTransactionByTransactionTime(newTransaction.TransactionTime, clientTimezone)
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, []*models.Transaction{transaction, newTransaction})
if err != nil {
log.Errorf(c, "[transactions.TransactionModifyHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
newTransactionEditable := user.CanEditTransactionByTransactionTime(newTransaction.TransactionTime, clientTimezone, allUsedAccounts[newTransaction.AccountId], allUsedAccounts[newTransaction.RelatedAccountId])
if !transactionEditable || !newTransactionEditable {
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
@@ -1338,6 +1354,579 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return newTransactionResp, nil
}
// TransactionBatchUpdateCategoriesHandler batch updates categories of transactions by request parameters for current user
func (a *TransactionsApi) TransactionBatchUpdateCategoriesHandler(c *core.WebContext) (any, *errs.Error) {
var transactionBatchUpdateReq models.TransactionBatchUpdateCategoryRequest
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] parse transaction ids failed, because %s", err.Error())
return nil, errs.ErrTransactionIdInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
category, err := a.transactionCategories.GetCategoryByCategoryId(c, uid, transactionBatchUpdateReq.CategoryId)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", transactionBatchUpdateReq.CategoryId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction category \"id:%d\" is not a sub category", category.CategoryId)
return nil, errs.ErrCannotUsePrimaryCategoryForTransaction
}
var expectedTransactionType models.TransactionDbType
if category.Type == models.CATEGORY_TYPE_EXPENSE {
expectedTransactionType = models.TRANSACTION_DB_TYPE_EXPENSE
} else if category.Type == models.CATEGORY_TYPE_INCOME {
expectedTransactionType = models.TRANSACTION_DB_TYPE_INCOME
} else if category.Type == models.CATEGORY_TYPE_TRANSFER {
expectedTransactionType = models.TRANSACTION_DB_TYPE_TRANSFER_OUT
}
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allTransactionIds := make([]int64, 0, len(transactions))
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type != expectedTransactionType {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction \"id:%d\" type is not expected type \"%d\" for user \"uid:%d\"", transaction.TransactionId, expectedTransactionType, uid)
return nil, errs.ErrTransactionTypeInvalid
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
if !transactionEditable {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
}
allTransactionIds = append(allTransactionIds, transaction.TransactionId)
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
allTransactionIds = append(allTransactionIds, transaction.RelatedId)
}
}
err = a.transactions.BatchUpdateTransactionsCategory(c, uid, allTransactionIds, category.CategoryId)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to batch update transactions category for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionBatchUpdateCategoriesHandler] user \"uid:%d\" has batch updated category of %d transactions successfully", uid, len(transactionBatchUpdateReq.TransactionIds))
return true, nil
}
// TransactionBatchUpdateAccountsHandler batch updates accounts of transactions by request parameters for current user
func (a *TransactionsApi) TransactionBatchUpdateAccountsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionBatchUpdateReq models.TransactionBatchUpdateAccountRequest
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] parse transaction ids failed, because %s", err.Error())
return nil, errs.ErrTransactionIdInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionBatchUpdateAccountsHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
allAccounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateAccountsHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountMapByList(allAccounts)
account, exists := accountMap[transactionBatchUpdateReq.AccountId]
if !exists || account == nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] account \"id:%d\" does not exist for user \"uid:%d\"", transactionBatchUpdateReq.AccountId, uid)
return nil, errs.ErrAccountNotFound
}
if account.Hidden {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] account \"id:%d\" is hidden for user \"uid:%d\"", account.AccountId, uid)
return nil, errs.ErrCannotMoveTransactionFromOrToHiddenAccount
}
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] account \"id:%d\" is a parent account, cannot be used for transaction of user \"uid:%d\"", account.AccountId, uid)
return nil, errs.ErrCannotModifyTransactionInParentAccount
}
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateAccountsHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot modify transaction \"id:%d\" for user \"uid:%d\", because transaction type is transfer in", transaction.TransactionId, uid)
return nil, errs.ErrTransactionTypeInvalid
}
if transactionBatchUpdateReq.IsDestinationAccount && transaction.Type != models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot update destination account of non-transfer transaction \"id:%d\" for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrTransactionDestinationAccountCannotBeSet
}
if !transactionBatchUpdateReq.IsDestinationAccount && account.AccountId == transaction.RelatedAccountId {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot update account to same destination account of transaction \"id:%d\" for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrTransactionSourceAndDestinationIdCannotBeEqual
} else if transactionBatchUpdateReq.IsDestinationAccount && account.AccountId == transaction.AccountId {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot update destination account to same source account of transaction \"id:%d\" for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrTransactionSourceAndDestinationIdCannotBeEqual
}
var oldAccount *models.Account
if !transactionBatchUpdateReq.IsDestinationAccount {
oldAccount = accountMap[transaction.AccountId]
} else if transactionBatchUpdateReq.IsDestinationAccount {
oldAccount = accountMap[transaction.RelatedAccountId]
}
if oldAccount == nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] the original account of transaction \"id:%d\" does not exist for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrAccountNotFound
}
if oldAccount.Hidden {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] the original account of transaction \"id:%d\" is hidden for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotMoveTransactionFromOrToHiddenAccount
}
if oldAccount.Currency != account.Currency {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] cannot update account of transaction \"id:%d\", because the original account currency \"%s\" is different from updated account currency \"%s\" for user \"uid:%d\"", transaction.TransactionId, oldAccount.Currency, account.Currency, uid)
return nil, errs.ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies
}
newSourceAccount := accountMap[transaction.AccountId]
newDestinationAccount := accountMap[transaction.RelatedAccountId]
if !transactionBatchUpdateReq.IsDestinationAccount && transaction.AccountId != account.AccountId {
newSourceAccount = account
} else if transactionBatchUpdateReq.IsDestinationAccount && transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT && transaction.RelatedAccountId != account.AccountId {
newDestinationAccount = account
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, accountMap[transaction.AccountId], accountMap[transaction.RelatedAccountId])
newTransactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, newSourceAccount, newDestinationAccount)
if !transactionEditable || !newTransactionEditable {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
}
}
updatedCount := 0
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if !transactionBatchUpdateReq.IsDestinationAccount && transaction.AccountId != account.AccountId {
transaction.AccountId = account.AccountId
} else if transactionBatchUpdateReq.IsDestinationAccount && transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT && transaction.RelatedAccountId != account.AccountId {
transaction.RelatedAccountId = account.AccountId
} else {
log.Warnf(c, "[transactions.TransactionBatchUpdateAccountsHandler] skip updating transaction \"id:%d\", because the original account is same as updated account for user \"uid:%d\"", transaction.TransactionId, uid)
continue
}
err = a.transactions.ModifyTransaction(c, transaction, 0, nil, nil, nil, nil)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateAccountsHandler] failed to update transaction \"id:%d\" for user \"uid:%d\", because %s", transaction.TransactionId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
updatedCount++
}
if updatedCount < 1 {
return nil, errs.ErrNothingWillBeUpdated
}
log.Infof(c, "[transactions.TransactionBatchUpdateAccountsHandler] user \"uid:%d\" has batch updated account of %d transactions successfully", uid, updatedCount)
return true, nil
}
// TransactionBatchAddTagsHandler batch add tags to transactions by request parameters for current user
func (a *TransactionsApi) TransactionBatchAddTagsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionBatchUpdateReq models.TransactionBatchAddTagsRequest
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] parse transaction ids failed, because %s", err.Error())
return nil, errs.ErrTransactionIdInvalid
}
tagIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] parse tag ids failed, because %s", err.Error())
return nil, errs.ErrTransactionTagIdInvalid
}
tagIds = utils.ToUniqueInt64Slice(tagIds)
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
tags, err := a.transactionTags.GetTagsByTagIds(c, uid, tagIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if len(tags) != len(tagIds) {
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] some tags do not exist for user \"uid:%d\"", uid)
return nil, errs.ErrTransactionTagNotFound
}
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
transactionTagIndexes, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, transactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to get transactions tag indexes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allNewTransactionTagIndexes := make(map[int64][]int64, len(transactions))
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] cannot modify transaction \"id:%d\" for user \"uid:%d\", because transaction type is transfer in", transaction.TransactionId, uid)
return nil, errs.ErrTransactionTypeInvalid
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
if !transactionEditable {
log.Warnf(c, "[transactions.TransactionBatchAddTagsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
}
existedTagIds := transactionTagIndexes[transaction.TransactionId]
existedTagIdsMap := make(map[int64]bool, len(existedTagIds))
for j := 0; j < len(existedTagIds); j++ {
existedTagIdsMap[existedTagIds[j]] = true
}
var newTagIds []int64
for j := 0; j < len(tagIds); j++ {
tagId := tagIds[j]
if _, exists := existedTagIdsMap[tagId]; !exists {
newTagIds = append(newTagIds, tagId)
}
}
allNewTransactionTagIndexes[transaction.TransactionId] = newTagIds
}
err = a.transactions.BatchAddTagsToTransactions(c, uid, transactions, allNewTransactionTagIndexes)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchAddTagsHandler] failed to batch update transactions tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionBatchAddTagsHandler] user \"uid:%d\" has batch updated tag of %d transactions successfully", uid, len(allNewTransactionTagIndexes))
return true, nil
}
// TransactionBatchRemoveTagsHandler batch remove tags from transactions by request parameters for current user
func (a *TransactionsApi) TransactionBatchRemoveTagsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionBatchUpdateReq models.TransactionBatchRemoveTagsRequest
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] parse transaction ids failed, because %s", err.Error())
return nil, errs.ErrTransactionIdInvalid
}
tagIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] parse tag ids failed, because %s", err.Error())
return nil, errs.ErrTransactionTagIdInvalid
}
tagIds = utils.ToUniqueInt64Slice(tagIds)
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
tags, err := a.transactionTags.GetTagsByTagIds(c, uid, tagIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if len(tags) != len(tagIds) {
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] some tags do not exist for user \"uid:%d\"", uid)
return nil, errs.ErrTransactionTagNotFound
}
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allTransactionIds := make([]int64, 0, len(transactions))
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] cannot modify transaction \"id:%d\" for user \"uid:%d\", because transaction type is transfer in", transaction.TransactionId, uid)
return nil, errs.ErrTransactionTypeInvalid
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
if !transactionEditable {
log.Warnf(c, "[transactions.TransactionBatchRemoveTagsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
}
allTransactionIds = append(allTransactionIds, transaction.TransactionId)
}
err = a.transactions.BatchRemoveTagsFromTransactions(c, uid, allTransactionIds, tagIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchRemoveTagsHandler] failed to batch update transactions tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionBatchRemoveTagsHandler] user \"uid:%d\" has batch updated tag of %d transactions successfully", uid, len(allTransactionIds))
return true, nil
}
// TransactionBatchClearTagsHandler batch clear all tags from transactions by request parameters for current user
func (a *TransactionsApi) TransactionBatchClearTagsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionBatchUpdateReq models.TransactionBatchClearTagsRequest
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] parse transaction ids failed, because %s", err.Error())
return nil, errs.ErrTransactionIdInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionBatchClearTagsHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchClearTagsHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchClearTagsHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allTransactionIds := make([]int64, 0, len(transactions))
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] cannot modify transaction \"id:%d\" for user \"uid:%d\", because transaction type is transfer in", transaction.TransactionId, uid)
return nil, errs.ErrTransactionTypeInvalid
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
if !transactionEditable {
log.Warnf(c, "[transactions.TransactionBatchClearTagsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
}
allTransactionIds = append(allTransactionIds, transaction.TransactionId)
}
err = a.transactions.BatchClearAllTagsFromTransactions(c, uid, allTransactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchClearTagsHandler] failed to batch update transactions tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionBatchClearTagsHandler] user \"uid:%d\" has batch updated tag of %d transactions successfully", uid, len(allTransactionIds))
return true, nil
}
// TransactionMoveAllBetweenAccountsHandler moves all transactions from one account to another account for current user
func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionMoveReq models.TransactionMoveBetweenAccountsRequest
@@ -1348,11 +1937,28 @@ func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebCo
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if transactionMoveReq.FromAccountId == transactionMoveReq.ToAccountId {
return nil, errs.ErrCannotMoveTransactionToSameAccount
}
uid := c.GetCurrentUid()
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, []int64{transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId})
if err != nil {
@@ -1384,7 +1990,38 @@ func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebCo
return nil, errs.ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies
}
err = a.transactions.MoveAllTransactionsBetweenAccounts(c, uid, transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId)
transactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{fromAccount.AccountId}, nil, false, "", "", false, pageCountForMovingAccountTransactions, true)
if err != nil {
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to get all transactions of account \"id:%d\" for user \"uid:%d\", because %s", fromAccount.AccountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
newTransactionEditable := transactionEditable
if transaction.AccountId == fromAccount.AccountId {
newTransactionEditable = user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, toAccount, allUsedAccounts[transaction.RelatedAccountId])
} else if transaction.RelatedAccountId == fromAccount.AccountId {
newTransactionEditable = user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], toAccount)
}
if !transactionEditable || !newTransactionEditable {
log.Warnf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
}
}
err = a.transactions.MoveAllTransactionsBetweenAccounts(c, uid, fromAccount.AccountId, toAccount.AccountId)
if err != nil {
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to move all transactions from account \"id:%d\" to account \"id:%d\" for user \"uid:%d\", because %s", transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId, uid, err.Error())
@@ -1435,7 +2072,14 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTypeInvalid
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, []*models.Transaction{transaction})
if err != nil {
log.Errorf(c, "[transactions.TransactionDeleteHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
if !transactionEditable {
return nil, errs.ErrCannotDeleteTransactionWithThisTransactionTime
@@ -1452,6 +2096,87 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
return true, nil
}
// TransactionBatchDeleteHandler deletes existed transactions by request parameters for current user
func (a *TransactionsApi) TransactionBatchDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var transactionBatchDeleteReq models.TransactionBatchDeleteRequest
err := c.ShouldBindJSON(&transactionBatchDeleteReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchDeleteHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchDeleteReq.Ids)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchDeleteHandler] parse transaction ids failed, because %s", err.Error())
return nil, errs.ErrTransactionIdInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionBatchDeleteHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(transactionBatchDeleteReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchDeleteHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, transactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchDeleteHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
if !transactionEditable {
log.Warnf(c, "[transactions.TransactionBatchDeleteHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotDeleteTransactionWithThisTransactionTime
}
}
deletedCount := 0
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
err = a.transactions.DeleteTransaction(c, uid, transaction.TransactionId)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchDeleteHandler] failed to delete transaction \"id:%d\" for user \"uid:%d\", because %s", transaction.TransactionId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
deletedCount++
}
log.Infof(c, "[transactions.TransactionBatchDeleteHandler] user \"uid:%d\" has deleted %d transactions", uid, deletedCount)
return true, nil
}
// TransactionParseImportCustomFileDataHandler returns the parsed file data by request parameters for current user
func (a *TransactionsApi) TransactionParseImportCustomFileDataHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
@@ -1861,13 +2586,24 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
for i := 0; i < len(transactionImportReq.Transactions); i++ {
transactionCreateReq := transactionImportReq.Transactions[i]
transaction := a.createNewTransactionModel(uid, transactionCreateReq, c.ClientIP())
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
if !transactionEditable {
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
newTransactions[i] = transaction
}
newTransactions[i] = transaction
allUsedAccounts, err := a.getTransactionUsedAccounts(c, uid, newTransactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionImportHandler] failed to get transaction used accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
for i := 0; i < len(newTransactions); i++ {
transaction := newTransactions[i]
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone, allUsedAccounts[transaction.AccountId], allUsedAccounts[transaction.RelatedAccountId])
if !transactionEditable {
log.Warnf(c, "[transactions.TransactionImportHandler] transaction \"index:%d\" is not editable for user \"uid:%d\"", i, uid)
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
}
}
err = a.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap, func(currentProcess float64) {
@@ -2099,6 +2835,41 @@ func (a *TransactionsApi) getTransactionEssentialDataByTransactionIds(c *core.We
return accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, nil
}
func (a *TransactionsApi) getTransactionUsedAccounts(c *core.WebContext, uid int64, transactions []*models.Transaction) (map[int64]*models.Account, error) {
accountIds := make([]int64, 0, len(transactions)*2)
for i := 0; i < len(transactions); i++ {
accountIds = append(accountIds, transactions[i].AccountId)
if transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN || transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
accountIds = append(accountIds, transactions[i].RelatedAccountId)
}
}
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, utils.ToUniqueInt64Slice(accountIds))
if err != nil {
log.Errorf(c, "[transactions.getTransactionUsedAccounts] failed to get accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
for i := 0; i < len(transactions); i++ {
if _, exists := accountMap[transactions[i].AccountId]; !exists {
log.Warnf(c, "[transactions.getTransactionUsedAccounts] account of transaction \"id:%d\" does not exist for user \"uid:%d\"", transactions[i].TransactionId, uid)
return nil, errs.ErrSourceAccountNotFound
}
if transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN || transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
if _, exists := accountMap[transactions[i].RelatedAccountId]; !exists {
log.Warnf(c, "[transactions.getTransactionUsedAccounts] related account of transaction \"id:%d\" does not exist for user \"uid:%d\"", transactions[i].TransactionId, uid)
return nil, errs.ErrDestinationAccountNotFound
}
}
}
return accountMap, nil
}
func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, user *models.User, transactions []*models.Transaction, allAccounts map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTransactionTagIds map[int64][]int64, pictureInfoMap map[int64][]*models.TransactionPictureInfo, clientTimezone *time.Location, withPictures bool, trimAccount bool, trimCategory bool, trimTag bool) (models.TransactionInfoResponseSlice, error) {
result := make(models.TransactionInfoResponseSlice, len(transactions))
+12 -1
View File
@@ -256,6 +256,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
userUpdateReq.Nickname = strings.TrimSpace(userUpdateReq.Nickname)
modifyProfileBasicInfo := false
modifyUseLastReconciledTime := false
anythingUpdate := false
userNew := &models.User{
Uid: user.Uid,
@@ -317,6 +318,16 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
anythingUpdate = true
}
if userUpdateReq.UseLastReconciledTime != nil && *userUpdateReq.UseLastReconciledTime != user.UseLastReconciledTime {
user.UseLastReconciledTime = *userUpdateReq.UseLastReconciledTime
userNew.UseLastReconciledTime = *userUpdateReq.UseLastReconciledTime
modifyProfileBasicInfo = true
modifyUseLastReconciledTime = true
anythingUpdate = true
} else {
modifyUseLastReconciledTime = false
}
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
@@ -531,7 +542,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
return nil, errs.ErrNothingWillBeUpdated
}
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage)
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage, modifyUseLastReconciledTime)
if err != nil {
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
@@ -20,10 +20,20 @@ type DataTableTransactionDataExporter struct {
// BuildExportedContent writes the exported transaction data to the data table builder
func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context, dataTableBuilder datatable.TransactionDataTableBuilder, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) error {
existsTransferOutTransactions := make(map[int64]bool)
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
existsTransferOutTransactions[transaction.TransactionId] = true
}
}
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN && existsTransferOutTransactions[transaction.RelatedId] {
continue
}
@@ -36,14 +46,25 @@ func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataTableBuilder.ReplaceDelimiters(c.getDisplayTransactionTypeName(transaction.Type))
dataRowMap[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = c.getExportedTransactionCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = c.getExportedTransactionSubCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
if transaction.Type != models.TRANSACTION_DB_TYPE_TRANSFER_IN {
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount)
} else { // if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
}
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
} else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.Amount)
}
dataRowMap[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = c.getExportedGeographicLocation(transaction)
@@ -383,7 +383,7 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
Comment: description,
GeoLongitude: geoLongitude,
GeoLatitude: geoLatitude,
CreatedIp: "127.0.0.1",
CreatedIp: ctx.ClientIP(),
},
TagIds: tagIds,
OriginalCategoryName: subCategoryName,
+1
View File
@@ -5,6 +5,7 @@ import "context"
// Context is the base context of ezBookkeeping
type Context interface {
context.Context
ClientIP() string
GetContextId() string
GetClientLocale() string
}
+5
View File
@@ -12,6 +12,11 @@ type CliContext struct {
command *cli.Command
}
// ClientIP returns the client IP address, for CLI context, it always returns the loopback address
func (c *CliContext) ClientIP() string {
return "127.0.0.1"
}
// GetContextId returns the current context id
func (c *CliContext) GetContextId() string {
return ""
+5
View File
@@ -14,6 +14,11 @@ type CronContext struct {
cronJobInterval time.Duration
}
// ClientIP returns the client IP address, for cron job context, it always returns the loopback address
func (c *CronContext) ClientIP() string {
return "127.0.0.1"
}
// GetContextId returns the current context id
func (c *CronContext) GetContextId() string {
return c.contextId
+5
View File
@@ -9,6 +9,11 @@ type NullContext struct {
context.Context
}
// ClientIP returns the client IP address, for null context, it always returns the loopback address
func (c *NullContext) ClientIP() string {
return "127.0.0.1"
}
// GetContextId returns the current context id
func (c *NullContext) GetContextId() string {
return nullContextId
+2
View File
@@ -26,4 +26,6 @@ var (
ErrNotSupportedChangeCurrency = NewNormalError(NormalSubcategoryAccount, 20, http.StatusBadRequest, "not supported to modify account currency")
ErrNotSupportedChangeBalance = NewNormalError(NormalSubcategoryAccount, 21, http.StatusBadRequest, "not supported to modify account balance")
ErrNotSupportedChangeBalanceTime = NewNormalError(NormalSubcategoryAccount, 22, http.StatusBadRequest, "not supported to modify account balance time")
ErrParentAccountCannotSetLastReconciledTime = NewNormalError(NormalSubcategoryAccount, 23, http.StatusBadRequest, "parent account cannot set last reconciled time")
ErrCannotSetLastReconciledTimeBeforeCurrent = NewNormalError(NormalSubcategoryAccount, 24, http.StatusBadRequest, "cannot set last reconciled time before current value")
)
+1
View File
@@ -45,4 +45,5 @@ var (
ErrCannotMoveTransactionFromOrToHiddenAccount = NewNormalError(NormalSubcategoryTransaction, 38, http.StatusBadRequest, "cannot move transaction from or to hidden account")
ErrCannotMoveTransactionFromOrToParentAccount = NewNormalError(NormalSubcategoryTransaction, 39, http.StatusBadRequest, "cannot move transaction from or to parent account")
ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies = NewNormalError(NormalSubcategoryTransaction, 40, http.StatusBadRequest, "cannot move transaction between accounts with different currencies")
ErrCannotAddTagsToTooManyTransactionsOneTime = NewNormalError(NormalSubcategoryTransaction, 41, http.StatusBadRequest, "cannot add tags to too many transactions one time")
)
+1
View File
@@ -41,4 +41,5 @@ var (
ErrCannotLoginByPassword = NewNormalError(NormalSubcategoryUser, 32, http.StatusBadRequest, "cannot login by password")
ErrUserNameIsInvalid = NewNormalError(NormalSubcategoryUser, 33, http.StatusBadRequest, "user name is invalid")
ErrNickNameIsInvalid = NewNormalError(NormalSubcategoryUser, 34, http.StatusBadRequest, "nick name is invalid")
ErrLastReconciledTimeIsNotEnabled = NewNormalError(NormalSubcategoryUser, 35, http.StatusBadRequest, "last reconciled time is not enabled")
)
@@ -24,7 +24,7 @@ func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *tes
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",
"JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PEN", "PLN", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD",
"USD", "VND", "ZAR"}
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
@@ -135,6 +135,22 @@ func TestExchangeRatesApiLatestExchangeRateHandler_BankOfIsraelDataSource(t *tes
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
}
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfKazakhstan(t *testing.T) {
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfKazakhstanDataSource)
if exchangeRateResponse == nil {
return
}
assert.Equal(t, "KZT", exchangeRateResponse.BaseCurrency)
supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK",
"DKK", "EUR", "GBP", "GEL", "HKD", "HUF", "INR", "IRR", "JPY", "KGS", "KRW", "KWD", "MDL", "MXN",
"MYR", "NOK", "PLN", "RUB", "SAR", "SEK", "SGD", "THB", "TJS", "TRY", "UAH", "USD", "UZS", "ZAR"}
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
}
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfMyanmarDataSource(t *testing.T) {
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfMyanmarDataSource)
@@ -270,7 +286,7 @@ func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfUzbekistanDataSo
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",
"BDT", "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",
@@ -40,6 +40,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error {
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfIsraelDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfKazakhstanDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfKazakhstanDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfMyanmarDataSource{})
return nil
@@ -0,0 +1,160 @@
package exchangerates
import (
"encoding/xml"
"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 nationalBankOfKazakhstanExchangeRateUrl = "https://www.nationalbank.kz/rss/rates_all.xml"
const nationalBankOfKazakhstanExchangeRateReferenceUrl = "https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut"
const nationalBankOfKazakhstanDataSource = "Қазақстан Республикасының Ұлттық Банкі"
const nationalBankOfKazakhstanBaseCurrency = "KZT"
const nationalBankOfKazakhstanUpdateDateFormat = "02.01.2006"
const nationalBankOfKazakhstanUpdateDateTimezone = "Asia/Almaty"
// NationalBankOfKazakhstanDataSource defines the structure of exchange rates data source of the national bank of Kazakhstan
type NationalBankOfKazakhstanDataSource struct {
HttpExchangeRatesDataSource
}
// NationalBankOfKazakhstanExchangeRates represents the exchange rates data from the national bank of Kazakhstan
type NationalBankOfKazakhstanExchangeRates struct {
Channel struct {
Items []*NationalBankOfKazakhstanExchangeRate `xml:"item"`
} `xml:"channel"`
}
// NationalBankOfKazakhstanExchangeRate represents the exchange rate data from the national bank of Kazakhstan
type NationalBankOfKazakhstanExchangeRate struct {
Currency string `xml:"title"`
Rate string `xml:"description"`
Unit string `xml:"quant"`
Date string `xml:"pubDate"`
}
// ToLatestExchangeRateResponse returns a view-object according to original data from the national bank of Kazakhstan
func (e *NationalBankOfKazakhstanExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
if e == nil || len(e.Channel.Items) < 1 {
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
return nil
}
timezone, err := time.LoadLocation(nationalBankOfKazakhstanUpdateDateTimezone)
if err != nil {
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] failed to load timezone, timezone name is %s", nationalBankOfKazakhstanUpdateDateTimezone)
return nil
}
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Channel.Items))
latestUpdateTime := int64(0)
for i := 0; i < len(e.Channel.Items); i++ {
exchangeRate := e.Channel.Items[i]
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
continue
}
updateTime, err := time.ParseInLocation(nationalBankOfKazakhstanUpdateDateFormat, exchangeRate.Date, timezone)
if err != nil {
log.Errorf(c, "[central_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Date)
return nil
}
if updateTime.Unix() > latestUpdateTime {
latestUpdateTime = updateTime.Unix()
}
finalRate := exchangeRate.ToLatestExchangeRate(c)
if finalRate == nil {
continue
}
exchangeRates = append(exchangeRates, finalRate)
}
return &models.LatestExchangeRateResponse{
DataSource: nationalBankOfKazakhstanDataSource,
ReferenceUrl: nationalBankOfKazakhstanExchangeRateReferenceUrl,
UpdateTime: latestUpdateTime,
BaseCurrency: nationalBankOfKazakhstanBaseCurrency,
ExchangeRates: exchangeRates,
}
}
// ToLatestExchangeRate returns a data pair according to original data from the national bank of Kazakhstan
func (e *NationalBankOfKazakhstanExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
rate, err := utils.StringToFloat64(e.Rate)
if err != nil {
log.Warnf(c, "[national_bank_of_kazakhstan_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_kazakhstan_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, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] failed to parse unit, currency=%s, unit=%s", e.Currency, e.Unit)
}
if unit <= 0 {
log.Warnf(c, "[national_bank_of_kazakhstan_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) {
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] final exchange rate calculation failed, currency is %s, unit is %s, rate is %s", e.Currency, e.Unit, e.Rate)
return nil
}
return &models.LatestExchangeRate{
Currency: e.Currency,
Rate: utils.Float64ToString(finalRate),
}
}
// BuildRequests returns the national bank of Kazakhstan exchange rates http requests
func (e *NationalBankOfKazakhstanDataSource) BuildRequests() ([]*http.Request, error) {
req, err := http.NewRequest("GET", nationalBankOfKazakhstanExchangeRateUrl, nil)
if err != nil {
return nil, err
}
return []*http.Request{req}, nil
}
// Parse returns the common response entity according to the national bank of Kazakhstan data source raw response
func (e *NationalBankOfKazakhstanDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
nationalBankOfKazakhstanData := &NationalBankOfKazakhstanExchangeRates{}
err := xml.Unmarshal(content, nationalBankOfKazakhstanData)
if err != nil {
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
latestExchangeRateResponse := nationalBankOfKazakhstanData.ToLatestExchangeRateResponse(c)
if latestExchangeRateResponse == nil {
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
return nil, errs.ErrFailedToRequestRemoteApi
}
return latestExchangeRateResponse, nil
}
@@ -0,0 +1,182 @@
package exchangerates
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
const nationalBankOfKazakhstanMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
" <rss version=\"2.0\">\n" +
" <channel>\n" +
" <item>\n" +
" <title>USD</title>\n" +
" <pubDate>28.04.2026</pubDate>\n" +
" <description>450.50</description>\n" +
" <quant>1</quant>\n" +
" </item>\n" +
" <item>\n" +
" <title>VND</title>\n" +
" <pubDate>28.04.2026</pubDate>\n" +
" <description>0.018</description>\n" +
" <quant>10</quant>\n" +
" </item>\n" +
" </channel>\n" +
"</rss>"
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
dataSource := &NationalBankOfKazakhstanDataSource{}
context := core.NewNullContext()
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
assert.Equal(t, nil, err)
assert.Equal(t, "KZT", resp.BaseCurrency)
}
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractUpdateTime(t *testing.T) {
dataSource := &NationalBankOfKazakhstanDataSource{}
context := core.NewNullContext()
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
assert.Equal(t, nil, err)
assert.Equal(t, int64(1777316400), resp.UpdateTime)
}
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractExchangeRates(t *testing.T) {
dataSource := &NationalBankOfKazakhstanDataSource{}
context := core.NewNullContext()
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
assert.Equal(t, nil, err)
assert.Contains(t, resp.ExchangeRates, &models.LatestExchangeRate{
Currency: "USD",
Rate: "0.0022197558268590455",
})
assert.Contains(t, resp.ExchangeRates, &models.LatestExchangeRate{
Currency: "VND",
Rate: "555.5555555555555",
})
}
func TestNationalBankOfKazakhstanDataSource_BlankContent(t *testing.T) {
dataSource := &NationalBankOfKazakhstanDataSource{}
context := core.NewNullContext()
_, err := dataSource.Parse(context, []byte(""))
assert.NotEqual(t, nil, err)
}
func TestNationalBankOfKazakhstanDataSource_EmptyData(t *testing.T) {
dataSource := &NationalBankOfKazakhstanDataSource{}
context := core.NewNullContext()
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
"<rss version=\"2.0\">\n" +
"<channel>\n" +
"</channel>\n" +
"</rss>"
_, err := dataSource.Parse(context, []byte(content))
assert.NotEqual(t, nil, err)
}
func TestNationalBankOfKazakhstanDataSource_InvalidCurrency(t *testing.T) {
dataSource := &NationalBankOfKazakhstanDataSource{}
context := core.NewNullContext()
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
" <rss version=\"2.0\">\n" +
" <channel>\n" +
" <item>\n" +
" <title>XXX</title>\n" +
" <pubDate>28.04.2026</pubDate>\n" +
" <description>450.50</description>\n" +
" <quant>1</quant>\n" +
" </item>\n" +
" </channel>\n" +
"</rss>"
resp, err := dataSource.Parse(context, []byte(content))
assert.Equal(t, nil, err)
assert.Len(t, resp.ExchangeRates, 0)
}
func TestNationalBankOfKazakhstanDataSource_InvalidUnit(t *testing.T) {
dataSource := &NationalBankOfKazakhstanDataSource{}
context := core.NewNullContext()
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
" <rss version=\"2.0\">\n" +
" <channel>\n" +
" <item>\n" +
" <title>USD</title>\n" +
" <pubDate>28.04.2026</pubDate>\n" +
" <description>450.50</description>\n" +
" <quant>null</quant>\n" +
" </item>\n" +
" </channel>\n" +
"</rss>"
resp, err := dataSource.Parse(context, []byte(content))
assert.Equal(t, nil, err)
assert.Len(t, resp.ExchangeRates, 0)
content = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
" <rss version=\"2.0\">\n" +
" <channel>\n" +
" <item>\n" +
" <title>USD</title>\n" +
" <pubDate>28.04.2026</pubDate>\n" +
" <description>450.50</description>\n" +
" <quant>0</quant>\n" +
" </item>\n" +
" </channel>\n" +
"</rss>"
resp, err = dataSource.Parse(context, []byte(content))
assert.Equal(t, nil, err)
assert.Len(t, resp.ExchangeRates, 0)
}
func TestNationalBankOfKazakhstanDataSource_InvalidRate(t *testing.T) {
dataSource := &NationalBankOfKazakhstanDataSource{}
context := core.NewNullContext()
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
" <rss version=\"2.0\">\n" +
" <channel>\n" +
" <item>\n" +
" <title>USD</title>\n" +
" <pubDate>28.04.2026</pubDate>\n" +
" <description>null</description>\n" +
" <quant>1</quant>\n" +
" </item>\n" +
" </channel>\n" +
"</rss>"
resp, err := dataSource.Parse(context, []byte(content))
assert.Equal(t, nil, err)
assert.Len(t, resp.ExchangeRates, 0)
content = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
" <rss version=\"2.0\">\n" +
" <channel>\n" +
" <item>\n" +
" <title>USD</title>\n" +
" <pubDate>28.04.2026</pubDate>\n" +
" <description>0</description>\n" +
" <quant>1</quant>\n" +
" </item>\n" +
" </channel>\n" +
"</rss>"
resp, err = dataSource.Parse(context, []byte(content))
assert.Equal(t, nil, err)
assert.Len(t, resp.ExchangeRates, 0)
}
+1 -1
View File
@@ -178,7 +178,7 @@ func (h *mcpAddTransactionToolHandler) Handle(c *core.WebContext, callToolReq *M
return nil, nil, err
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60))
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60), sourceAccount, destinationAccount)
if !transactionEditable {
return nil, nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
+4 -2
View File
@@ -14,6 +14,8 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const pageCountForLoadTransactions = 1000
// MCPQueryTransactionsRequest represents all parameters of the query transactions request
type MCPQueryTransactionsRequest struct {
StartTime string `json:"start_time" jsonschema:"format=date-time" jsonschema_description:"Start time for the query in RFC 3339 format (e.g. 2023-01-01T12:00:00Z)"`
@@ -153,14 +155,14 @@ func (h *mcpQueryTransactionsToolHandler) Handle(c *core.WebContext, callToolReq
}
}
totalCount, err := services.GetTransactionService().GetTransactionCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword)
totalCount, err := services.GetTransactionService().GetTransactionCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, false)
if err != nil {
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
return nil, nil, err
}
transactions, err := services.GetTransactionService().GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, queryTransactionsRequest.Page, queryTransactionsRequest.Count, false, true)
transactions, err := services.GetTransactionService().GetTransactionsByMaxTimeUpToCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, false, queryTransactionsRequest.Page, queryTransactionsRequest.Count, pageCountForLoadTransactions, false, true)
structuredResponse, response, err := h.createNewMCPQueryTransactionsResponse(c, &queryTransactionsRequest, transactions, totalCount, services.GetAccountService().GetAccountMapByList(allAccounts), services.GetTransactionCategoryService().GetCategoryMapByList(allCategories))
if err != nil {
+24
View File
@@ -90,6 +90,7 @@ type Account struct {
// AccountExtend represents account extend data stored in database
type AccountExtend struct {
LastReconciledTime *int64 `json:"lastReconciledTime"`
CreditCardStatementDate *int `json:"creditCardStatementDate"`
}
@@ -119,6 +120,7 @@ type AccountModifyRequest struct {
Currency *string `json:"currency" binding:"omitempty,len=3,validCurrency"`
Balance *int64 `json:"balance" binding:"omitempty"`
BalanceTime *int64 `json:"balanceTime" binding:"omitempty"`
LastReconciledTime *int64 `json:"lastReconciledTime" binding:"omitempty"`
Comment string `json:"comment" binding:"max=255"`
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
Hidden bool `json:"hidden"`
@@ -126,6 +128,12 @@ type AccountModifyRequest struct {
ClientSessionId string `json:"clientSessionId"`
}
// AccountUpdateLastReconciledTimeRequest represents all parameters of account updating last reconciled time request
type AccountUpdateLastReconciledTimeRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
LastReconciledTime int64 `json:"lastReconciledTime" binding:"required"`
}
// AccountListRequest represents all parameters of account listing request
type AccountListRequest struct {
VisibleOnly bool `form:"visible_only"`
@@ -169,6 +177,7 @@ type AccountInfoResponse struct {
Color string `json:"color"`
Currency string `json:"currency"`
Balance int64 `json:"balance"`
LastReconciledTime *int64 `json:"lastReconciledTime,omitempty"`
Comment string `json:"comment"`
CreditCardStatementDate *int `json:"creditCardStatementDate,omitempty"`
DisplayOrder int32 `json:"displayOrder"`
@@ -178,10 +187,24 @@ type AccountInfoResponse struct {
SubAccounts AccountInfoResponseSlice `json:"subAccounts,omitempty"`
}
// GetLastReconciledTime returns the last reconciled time of the account
func (a *Account) GetLastReconciledTime() int64 {
if a.Extend != nil && a.Extend.LastReconciledTime != nil {
return *a.Extend.LastReconciledTime
}
return 0
}
// ToAccountInfoResponse returns a view-object according to database model
func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
var lastReconciledTime *int64
var creditCardStatementDate *int
if a.Extend != nil {
lastReconciledTime = a.Extend.LastReconciledTime
}
if a.ParentAccountId == LevelOneAccountParentId && a.Category == ACCOUNT_CATEGORY_CREDIT_CARD {
if a.Extend != nil {
creditCardStatementDate = a.Extend.CreditCardStatementDate
@@ -201,6 +224,7 @@ func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
Currency: a.Currency,
Balance: a.Balance,
Comment: a.Comment,
LastReconciledTime: lastReconciledTime,
CreditCardStatementDate: creditCardStatementDate,
DisplayOrder: a.DisplayOrder,
IsAsset: assetAccountCategory[a.Category],
+44 -4
View File
@@ -216,6 +216,7 @@ type TransactionCountRequest struct {
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
Keyword string `form:"keyword"`
MustHavePictures bool `form:"must_have_pictures"`
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
}
@@ -228,6 +229,7 @@ type TransactionListByMaxTimeRequest struct {
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
Keyword string `form:"keyword"`
MustHavePictures bool `form:"must_have_pictures"`
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
Page int32 `form:"page" binding:"min=0"`
@@ -249,6 +251,7 @@ type TransactionListInMonthByPageRequest struct {
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
Keyword string `form:"keyword"`
MustHavePictures bool `form:"must_have_pictures"`
WithPictures bool `form:"with_pictures"`
TrimAccount bool `form:"trim_account"`
TrimCategory bool `form:"trim_category"`
@@ -263,6 +266,7 @@ type TransactionAllListRequest struct {
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
Keyword string `form:"keyword"`
MustHavePictures bool `form:"must_have_pictures"`
StartTime int64 `form:"start_time" binding:"min=0"`
EndTime int64 `form:"end_time" binding:"min=0"`
WithPictures bool `form:"with_pictures"`
@@ -325,6 +329,36 @@ type TransactionGetRequest struct {
TrimTag bool `form:"trim_tag"`
}
// TransactionBatchUpdateCategoryRequest represents all parameters of transaction batch update category request
type TransactionBatchUpdateCategoryRequest struct {
TransactionIds []string `json:"transactionIds" binding:"required"`
CategoryId int64 `json:"categoryId,string" binding:"required"`
}
// TransactionBatchUpdateAccountRequest represents all parameters of transaction batch update account request
type TransactionBatchUpdateAccountRequest struct {
TransactionIds []string `json:"transactionIds" binding:"required"`
AccountId int64 `json:"accountId,string" binding:"required"`
IsDestinationAccount bool `json:"isDestinationAccount"`
}
// TransactionBatchAddTagsRequest represents all parameters of transaction batch add tags request
type TransactionBatchAddTagsRequest struct {
TransactionIds []string `json:"transactionIds" binding:"required"`
TagIds []string `json:"tagIds" binding:"required"`
}
// TransactionBatchRemoveTagsRequest represents all parameters of transaction batch remove tags request
type TransactionBatchRemoveTagsRequest struct {
TransactionIds []string `json:"transactionIds" binding:"required"`
TagIds []string `json:"tagIds" binding:"required"`
}
// TransactionBatchClearTagsRequest represents all parameters of transaction batch clear tags request
type TransactionBatchClearTagsRequest struct {
TransactionIds []string `json:"transactionIds" binding:"required"`
}
// TransactionMoveBetweenAccountsRequest represents all parameters of moving all transactions between accounts request
type TransactionMoveBetweenAccountsRequest struct {
FromAccountId int64 `json:"fromAccountId,string" binding:"required,min=1"`
@@ -336,6 +370,12 @@ type TransactionDeleteRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
}
// TransactionBatchDeleteRequest represents all parameters of transaction batch deleting request
type TransactionBatchDeleteRequest struct {
Ids []string `json:"ids,string" binding:"required"`
Password string `json:"password" binding:"omitempty,min=6,max=128"`
}
// YearMonthRangeRequest represents all parameters of a request with year and month range
type YearMonthRangeRequest struct {
StartYearMonth string `form:"start_year_month"`
@@ -513,10 +553,6 @@ func ParseTransactionTagFilter(tagFilterStr string) ([]*TransactionTagFilter, er
// IsEditable returns whether this transaction can be edited
func (t *Transaction) IsEditable(currentUser *User, clientTimezone *time.Location, account *Account, relatedAccount *Account) bool {
if currentUser == nil || !currentUser.CanEditTransactionByTransactionTime(t.TransactionTime, clientTimezone) {
return false
}
if account == nil || account.Hidden {
return false
}
@@ -527,6 +563,10 @@ func (t *Transaction) IsEditable(currentUser *User, clientTimezone *time.Locatio
}
}
if currentUser == nil || !currentUser.CanEditTransactionByTransactionTime(t.TransactionTime, clientTimezone, account, relatedAccount) {
return false
}
return true
}
+2
View File
@@ -24,6 +24,8 @@ const (
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED TransactionScheduleFrequencyType = 0
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY TransactionScheduleFrequencyType = 1
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY TransactionScheduleFrequencyType = 2
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY TransactionScheduleFrequencyType = 3
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY TransactionScheduleFrequencyType = 4
)
// TransactionTemplate represents transaction template stored in database
+30 -7
View File
@@ -20,6 +20,7 @@ const (
TRANSACTION_EDIT_SCOPE_THIS_WEEK_OR_LATER TransactionEditScope = 4
TRANSACTION_EDIT_SCOPE_THIS_MONTH_OR_LATER TransactionEditScope = 5
TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER TransactionEditScope = 6
TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER TransactionEditScope = 7
TRANSACTION_EDIT_SCOPE_INVALID TransactionEditScope = 255
)
@@ -40,6 +41,8 @@ func (s TransactionEditScope) String() string {
return "ThisMonthOrLater"
case TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER:
return "ThisYearOrLater"
case TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER:
return "LastReconciledTimeOrLater"
case TRANSACTION_EDIT_SCOPE_INVALID:
return "Invalid"
default:
@@ -90,6 +93,7 @@ type User struct {
Salt string `xorm:"VARCHAR(10) NOT NULL"`
CustomAvatarType string `xorm:"VARCHAR(10)"`
DefaultAccountId int64
UseLastReconciledTime bool
TransactionEditScope TransactionEditScope `xorm:"TINYINT NOT NULL"`
Language string `xorm:"VARCHAR(10)"`
DefaultCurrency string `xorm:"VARCHAR(3) NOT NULL"`
@@ -128,6 +132,7 @@ type UserBasicInfo struct {
AvatarUrl string `json:"avatar"`
AvatarProvider string `json:"avatarProvider,omitempty"`
DefaultAccountId int64 `json:"defaultAccountId,string"`
UseLastReconciledTime bool `json:"useLastReconciledTime"`
TransactionEditScope TransactionEditScope `json:"transactionEditScope"`
Language string `json:"language"`
DefaultCurrency string `json:"defaultCurrency"`
@@ -194,7 +199,8 @@ type UserProfileUpdateRequest struct {
Password string `json:"password" binding:"omitempty,min=6,max=128"`
OldPassword string `json:"oldPassword" binding:"omitempty,min=6,max=128"`
DefaultAccountId int64 `json:"defaultAccountId,string" binding:"omitempty,min=1"`
TransactionEditScope *TransactionEditScope `json:"transactionEditScope" binding:"omitempty,min=0,max=6"`
UseLastReconciledTime *bool `json:"useLastReconciledTime" binding:"omitempty"`
TransactionEditScope *TransactionEditScope `json:"transactionEditScope" binding:"omitempty,min=0,max=7"`
Language string `json:"language" binding:"omitempty,min=2,max=16"`
DefaultCurrency string `json:"defaultCurrency" binding:"omitempty,len=3,validCurrency"`
FirstDayOfWeek *core.WeekDay `json:"firstDayOfWeek" binding:"omitempty,min=0,max=6"`
@@ -230,7 +236,7 @@ type UserProfileResponse struct {
}
// CanEditTransactionByTransactionTime returns whether this user can edit transaction with specified transaction time
func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, clientTimezone *time.Location) bool {
func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, clientTimezone *time.Location, account *Account, destinationAccount *Account) bool {
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_NONE {
return false
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_ALL {
@@ -242,14 +248,14 @@ func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, client
transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transactionTime)
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_LAST_24H_OR_LATER {
return transactionUnixTime >= now.Add(-24*time.Hour).Unix()
return transactionUnixTime > now.Add(-24*time.Hour).Unix()
}
clientNow := now.In(clientTimezone)
clientTodayStartTime := utils.GetStartOfDay(clientNow)
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_TODAY_OR_LATER {
return transactionUnixTime >= clientTodayStartTime.Unix()
return transactionUnixTime > clientTodayStartTime.Unix()
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_WEEK_OR_LATER {
dayOfWeek := int(now.Weekday()) - int(u.FirstDayOfWeek)
@@ -258,13 +264,29 @@ func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, client
}
clientWeekStartTime := clientTodayStartTime.AddDate(0, 0, -dayOfWeek)
return transactionUnixTime >= clientWeekStartTime.Unix()
return transactionUnixTime > clientWeekStartTime.Unix()
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_MONTH_OR_LATER {
clientMonthStartTime := clientTodayStartTime.AddDate(0, 0, -(now.Day() - 1))
return transactionUnixTime >= clientMonthStartTime.Unix()
return transactionUnixTime > clientMonthStartTime.Unix()
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER {
clientYearStartTime := clientTodayStartTime.AddDate(0, 0, -(now.YearDay() - 1))
return transactionUnixTime >= clientYearStartTime.Unix()
return transactionUnixTime > clientYearStartTime.Unix()
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER && u.UseLastReconciledTime {
minAccountLastReconciledTime := int64(0)
if account != nil {
minAccountLastReconciledTime = account.GetLastReconciledTime()
}
if destinationAccount != nil {
destinationAccountLastReconciledTime := destinationAccount.GetLastReconciledTime()
if destinationAccountLastReconciledTime > minAccountLastReconciledTime {
minAccountLastReconciledTime = destinationAccountLastReconciledTime
}
}
return transactionUnixTime > minAccountLastReconciledTime
}
return false
@@ -285,6 +307,7 @@ func (u *User) ToUserBasicInfo(avatarProvider core.UserAvatarProviderType, avata
AvatarUrl: avatarUrl,
AvatarProvider: string(avatarProvider),
DefaultAccountId: u.DefaultAccountId,
UseLastReconciledTime: u.UseLastReconciledTime,
TransactionEditScope: u.TransactionEditScope,
Language: u.Language,
DefaultCurrency: u.DefaultCurrency,
+2
View File
@@ -44,6 +44,8 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
"totalAmountExcludeAccountIds": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP,
"accountCategoryOrders": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
"hideCategoriesWithoutAccounts": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
"reconciliationStatementButtonDefaultDateRangeTypeInDesktop": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
"reconciliationStatementPageDefaultDateRangeTypeInMobile": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
// Exchange Rates Data Page
"currencySortByInExchangeRatesPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
// Browser Cache Management
+85 -16
View File
@@ -16,7 +16,7 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsNone(t *testing.T) {
}
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone, nil, nil))
}
func TestUserCanEditTransactionByTransactionTime_ScopeIsAll(t *testing.T) {
@@ -25,7 +25,7 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsAll(t *testing.T) {
}
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone, nil, nil))
}
func TestUserCanEditTransactionByTransactionTime_ScopeIsTodayOrLater(t *testing.T) {
@@ -39,9 +39,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsTodayOrLater(t *testing.
yesterdayLastDatetime := todayFirstDatetime.Add(-1 * time.Second)
todayLastDatetime := yesterdayLastDatetime.Add(24 * time.Hour)
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Unix()), timezone))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayLastDatetime.Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(yesterdayLastDatetime.Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayLastDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(yesterdayLastDatetime.Unix()), timezone, nil, nil))
}
func TestUserCanEditTransactionByTransactionTime_ScopeIsLast24HourOrLater(t *testing.T) {
@@ -53,8 +54,9 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsLast24HourOrLater(t *tes
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
twentyfourHourBeforeDatetime := now.Add(-24 * time.Hour).Add(-1 * time.Second)
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Unix()), timezone))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(1*time.Second).Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(2*time.Second).Unix()), timezone, nil, nil))
}
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisWeekOrLater(t *testing.T) {
@@ -76,9 +78,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisWeekOrLater(t *testi
lastWeekLastDatetime := thisWeekFirstDatetime.Add(-1 * time.Second)
thisWeekLastDatetime := lastWeekLastDatetime.Add(24 * time.Hour)
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Unix()), timezone))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekLastDatetime.Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastWeekLastDatetime.Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekLastDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastWeekLastDatetime.Unix()), timezone, nil, nil))
}
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisMonthOrLater(t *testing.T) {
@@ -92,9 +95,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisMonthOrLater(t *test
lastMonthLastDatetime := thisMonthFirstDatetime.Add(-1 * time.Second)
thisMonthLastDatetime := lastMonthLastDatetime.Add(24 * time.Hour)
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Unix()), timezone))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthLastDatetime.Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastMonthLastDatetime.Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthLastDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastMonthLastDatetime.Unix()), timezone, nil, nil))
}
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisYearOrLater(t *testing.T) {
@@ -108,7 +112,72 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisYearOrLater(t *testi
lastYearLastDatetime := thisYearFirstDatetime.Add(-1 * time.Second)
thisYearLastDatetime := lastYearLastDatetime.Add(24 * time.Hour)
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Unix()), timezone))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearLastDatetime.Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastYearLastDatetime.Unix()), timezone))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearLastDatetime.Unix()), timezone, nil, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastYearLastDatetime.Unix()), timezone, nil, nil))
}
func TestUserCanEditTransactionByTransactionTime_ScopeIsLastReconciledTimeOrLater(t *testing.T) {
user := &User{
TransactionEditScope: TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER,
UseLastReconciledTime: true,
}
now := time.Now()
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
sourceAccountLastReconciledTime := now.Add(-24 * time.Hour)
sourceAccountLastRecondiledUnixTime := sourceAccountLastReconciledTime.Unix()
sourceAccount := &Account{
Extend: &AccountExtend{
LastReconciledTime: &sourceAccountLastRecondiledUnixTime,
},
}
destinationAccountLastReconciledTime := now.Add(-20 * time.Hour)
destinationAccountLastReconciledUnixTime := destinationAccountLastReconciledTime.Unix()
destinationAccount := &Account{
Extend: &AccountExtend{
LastReconciledTime: &destinationAccountLastReconciledUnixTime,
},
}
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Unix()), timezone, sourceAccount, nil))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Unix()), timezone, sourceAccount, destinationAccount))
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
}
func TestUserCanEditTransactionByTransactionTime_ScopeIsLastReconciledTimeOrLaterButUserDoesNotUseLastReconciledTime(t *testing.T) {
user := &User{
TransactionEditScope: TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER,
UseLastReconciledTime: false,
}
now := time.Now()
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
sourceAccountLastReconciledTime := now.Add(-24 * time.Hour)
sourceAccountLastRecondiledUnixTime := sourceAccountLastReconciledTime.Unix()
sourceAccount := &Account{
Extend: &AccountExtend{
LastReconciledTime: &sourceAccountLastRecondiledUnixTime,
},
}
destinationAccountLastReconciledTime := now.Add(-20 * time.Hour)
destinationAccountLastReconciledUnixTime := destinationAccountLastReconciledTime.Unix()
destinationAccount := &Account{
Extend: &AccountExtend{
LastReconciledTime: &destinationAccountLastReconciledUnixTime,
},
}
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Unix()), timezone, sourceAccount, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, nil))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Unix()), timezone, sourceAccount, destinationAccount))
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
}
+21
View File
@@ -592,6 +592,27 @@ func (s *AccountService) ModifyAccounts(c core.Context, mainAccount *models.Acco
})
}
// UpdateAccountExtend updates extend field of given account
func (s *AccountService) UpdateAccountExtend(c core.Context, uid int64, account *models.Account) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
account.UpdatedUnixTime = time.Now().Unix()
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
updatedRows, err := sess.ID(account.AccountId).Cols("extend", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(account)
if err != nil {
return err
} else if updatedRows < 1 {
return errs.ErrAccountNotFound
}
return nil
})
}
// HideAccount updates hidden field of given accounts
func (s *AccountService) HideAccount(c core.Context, uid int64, ids []int64, hidden bool) error {
if uid <= 0 {
+362 -39
View File
@@ -55,7 +55,7 @@ func (s *TransactionService) GetAllTransactions(c core.Context, uid int64, pageC
var allTransactions []*models.Transaction
for maxTransactionTime > 0 {
transactions, err := s.GetAllTransactionsByMaxTime(c, uid, maxTransactionTime, pageCount, noDuplicated)
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", false, 1, pageCount, false, noDuplicated)
if err != nil {
return nil, err
@@ -74,13 +74,8 @@ func (s *TransactionService) GetAllTransactions(c core.Context, uid int64, pageC
return allTransactions, nil
}
// 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)
}
// GetAllSpecifiedTransactions returns all transactions that match given conditions
func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, pageCount int32, noDuplicated bool) ([]*models.Transaction, error) {
func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, pageCount int32, noDuplicated bool) ([]*models.Transaction, error) {
if maxTransactionTime <= 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
}
@@ -88,7 +83,7 @@ func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int
var allTransactions []*models.Transaction
for maxTransactionTime > 0 {
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, 1, pageCount, false, noDuplicated)
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, 1, pageCount, false, noDuplicated)
if err != nil {
return nil, err
@@ -116,7 +111,7 @@ func (s *TransactionService) GetAllTransactionsInOneAccountWithAccountBalanceByM
var allTransactions []*models.Transaction
for maxTransactionTime > 0 {
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, []int64{accountId}, nil, false, "", "", 1, pageCount, false, true)
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, []int64{accountId}, nil, false, "", "", false, 1, pageCount, false, true)
if err != nil {
return nil, 0, 0, 0, 0, err
@@ -206,7 +201,7 @@ func (s *TransactionService) GetAllAccountsDailyOpeningAndClosingBalance(c core.
var allTransactions []*models.Transaction
for maxTransactionTime > 0 {
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", 1, pageCountForLoadTransactionAmounts, false, false)
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", false, 1, pageCountForLoadTransactionAmounts, false, false)
if err != nil {
return nil, err
@@ -322,8 +317,103 @@ func (s *TransactionService) GetAllAccountsDailyOpeningAndClosingBalance(c core.
return accountDailyBalances, nil
}
// GetTransactionsByMaxTimeUpToCount returns transactions before given time and up to given count
func (s *TransactionService) GetTransactionsByMaxTimeUpToCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, page int32, count int32, pageCount int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
if maxTransactionTime <= 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
}
if page < 0 {
return nil, errs.ErrPageIndexInvalid
} else if page == 0 {
page = 1
}
if count < 1 {
return nil, errs.ErrPageCountInvalid
}
finalExpectedCount := int(count)
if needOneMoreItem {
finalExpectedCount++
}
var allTransactions []*models.Transaction
startOffset := int((page - 1) * count)
firstFetchCount := int(pageCount)
if finalExpectedCount < firstFetchCount {
firstFetchCount = finalExpectedCount
}
transactions, err := s.getTransactionsByMaxTimeWithOffset(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, startOffset, firstFetchCount, noDuplicated)
if err != nil {
return nil, err
}
allTransactions = append(allTransactions, transactions...)
if len(transactions) < firstFetchCount {
return allTransactions, nil
}
maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1
for len(allTransactions) < finalExpectedCount && maxTransactionTime > 0 {
remainingCount := finalExpectedCount - len(allTransactions)
fetchCount := int(pageCount)
if remainingCount < fetchCount {
fetchCount = remainingCount
}
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, 1, int32(fetchCount), false, noDuplicated)
if err != nil {
return nil, err
}
allTransactions = append(allTransactions, transactions...)
if len(transactions) < fetchCount {
break
}
maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1
}
return allTransactions, nil
}
// GetTransactionsByMaxTime returns transactions before given time
func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, 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.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
if uid <= 0 {
return nil, errs.ErrUserIdInvalid
}
if page < 0 {
return nil, errs.ErrPageIndexInvalid
} else if page == 0 {
page = 1
}
if count < 1 {
return nil, errs.ErrPageCountInvalid
}
finalCount := int(count)
if needOneMoreItem {
finalCount++
}
return s.getTransactionsByMaxTimeWithOffset(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, int(count*(page-1)), finalCount, noDuplicated)
}
// getTransactionsByMaxTimeWithOffset returns transactions before given time with explicit offset and limit
func (s *TransactionService) getTransactionsByMaxTimeWithOffset(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, offset int, limit int, noDuplicated bool) ([]*models.Transaction, error) {
if uid <= 0 {
return nil, errs.ErrUserIdInvalid
}
@@ -339,35 +429,20 @@ func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64,
}
}
if page < 0 {
return nil, errs.ErrPageIndexInvalid
} else if page == 0 {
page = 1
}
if count < 1 {
return nil, errs.ErrPageCountInvalid
}
var transactions []*models.Transaction
actualCount := count
if needOneMoreItem {
actualCount++
}
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, noDuplicated)
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
err = sess.Limit(int(actualCount), int(count*(page-1))).OrderBy("transaction_time desc").Find(&transactions)
err = sess.Limit(limit, offset).OrderBy("transaction_time desc").Find(&transactions)
return transactions, err
}
// GetTransactionsInMonthByPage returns all transactions in given year and month
func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string) ([]*models.Transaction, error) {
func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool) ([]*models.Transaction, error) {
if uid <= 0 {
return nil, errs.ErrUserIdInvalid
}
@@ -394,6 +469,7 @@ func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid in
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, true)
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
err = sess.OrderBy("transaction_time desc").Find(&transactions)
@@ -434,13 +510,29 @@ func (s *TransactionService) GetTransactionByTransactionId(c core.Context, uid i
return transaction, nil
}
// GetTransactionsByTransactionIds returns transaction models according to transaction ids
func (s *TransactionService) GetTransactionsByTransactionIds(c core.Context, uid int64, transactionIds []int64) ([]*models.Transaction, error) {
if uid <= 0 {
return nil, errs.ErrUserIdInvalid
}
if len(transactionIds) <= 0 {
return nil, errs.ErrTransactionIdInvalid
}
var transactions []*models.Transaction
err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).In("transaction_id", transactionIds).Find(&transactions)
return transactions, err
}
// 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, "", "", false)
}
// GetTransactionCount returns count of transactions
func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string) (int64, error) {
func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool) (int64, error) {
if uid <= 0 {
return 0, errs.ErrUserIdInvalid
}
@@ -459,6 +551,7 @@ func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxT
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, true)
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
return sess.Count(&models.Transaction{})
}
@@ -683,7 +776,20 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
for i := 0; i < s.UserDataDBCount(); i++ {
var templates []*models.TransactionTemplate
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND (scheduled_start_time IS NULL OR scheduled_start_time<=?) AND (scheduled_end_time IS NULL OR scheduled_end_time>=?) AND scheduled_at>=? AND scheduled_at<?", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, startTime.Unix(), startTime.Unix(), minScheduledAt, maxScheduledAt).Find(&templates)
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=?"+
" AND template_type=?"+
" AND (scheduled_frequency_type=? OR scheduled_frequency_type=? OR scheduled_frequency_type=? OR scheduled_frequency_type=?)"+
" AND (scheduled_start_time IS NULL OR scheduled_start_time<=?)"+
" AND (scheduled_end_time IS NULL OR scheduled_end_time>=?)"+
" AND scheduled_at>=?"+
" AND scheduled_at<?",
false,
models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE,
models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY,
startTime.Unix(),
startTime.Unix(),
minScheduledAt,
maxScheduledAt).Find(&templates)
if err != nil {
return err
@@ -712,7 +818,9 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
}
if (template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY &&
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY) ||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY &&
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY &&
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY) ||
template.ScheduledFrequency == "" {
skipCount++
log.Warnf(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" has invalid scheduled transaction frequency", template.TemplateId)
@@ -727,6 +835,16 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
continue
}
if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY {
maxDayInMonth := utils.GetMaxDayOfMonth(currentTime.Year(), currentTime.Month())
for i := 0; i < len(frequencyValues); i++ {
if frequencyValues[i] < 0 {
frequencyValues[i] = int64(maxDayInMonth) + frequencyValues[i] + 1
}
}
}
frequencyValueSet := utils.ToSet(frequencyValues)
templateTimeZone := time.FixedZone("Template Timezone", int(template.ScheduledTimezoneUtcOffset)*60)
transactionUnixTime := todayFirstUnixTimeInUTC + int64(template.ScheduledAt)*60
@@ -740,6 +858,10 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
skipCount++
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, today is %d of month", template.TemplateId, startTimeInUTC.Day())
continue
} else if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY && !frequencyValueSet[int64(transactionTime.Month())*100+int64(transactionTime.Day())] {
skipCount++
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, today is %d-%d of year", template.TemplateId, startTimeInUTC.Month(), startTimeInUTC.Day())
continue
}
if template.ScheduledStartTime != nil && *template.ScheduledStartTime > transactionUnixTime {
@@ -778,7 +900,7 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
Amount: template.Amount,
HideAmount: template.HideAmount,
Comment: template.Comment,
CreatedIp: "127.0.0.1",
CreatedIp: c.ClientIP(),
ScheduledCreated: true,
}
@@ -988,7 +1110,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
}
// Get and verify tags
err = s.isTagsValid(sess, transaction, transactionTagIndexes, addTagIds)
err = s.isTagsValid(sess, transaction.Uid, transactionTagIndexes, addTagIds)
if err != nil {
return err
@@ -1293,6 +1415,196 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
return nil
}
// BatchUpdateTransactionsCategory batch updates the categories of transactions
func (s *TransactionService) BatchUpdateTransactionsCategory(c core.Context, uid int64, transactionIds []int64, newCategoryId int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
if len(transactionIds) < 1 {
return errs.ErrTransactionIdInvalid
}
if newCategoryId <= 0 {
return errs.ErrTransactionCategoryIdInvalid
}
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
now := time.Now().Unix()
updateModel := &models.Transaction{
CategoryId: newCategoryId,
UpdatedUnixTime: now,
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
updatedRows, err := sess.Cols("category_id", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).Update(updateModel)
if err != nil {
return err
} else if updatedRows < int64(len(uniqueTransactionIds)) {
return errs.ErrTransactionNotFound
}
return err
})
}
// BatchAddTagsToTransactions batch adds tags to transactions
func (s *TransactionService) BatchAddTagsToTransactions(c core.Context, uid int64, transactions []*models.Transaction, addTransactionTagIds map[int64][]int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
if len(addTransactionTagIds) < 1 {
return errs.ErrTransactionIdInvalid
}
now := time.Now().Unix()
transactionTagIndexes := make([]*models.TransactionTagIndex, 0, len(addTransactionTagIds))
transactionsMap := make(map[int64]*models.Transaction, len(transactions))
transactionTagIdsMap := make(map[int64]bool, 0)
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
transactionsMap[transaction.TransactionId] = transaction
}
for transactionId, tagIds := range addTransactionTagIds {
if transactionId <= 0 {
return errs.ErrTransactionIdInvalid
}
transaction, exists := transactionsMap[transactionId]
if !exists || transaction == nil {
return errs.ErrTransactionNotFound
}
tagIds = utils.ToUniqueInt64Slice(tagIds)
for i := 0; i < len(tagIds); i++ {
tagId := tagIds[i]
if tagId <= 0 {
return errs.ErrTransactionTagIdInvalid
}
transactionTagIndexes = append(transactionTagIndexes, &models.TransactionTagIndex{
Uid: uid,
Deleted: false,
TransactionTime: transaction.TransactionTime,
TagId: tagId,
TransactionId: transactionId,
CreatedUnixTime: now,
UpdatedUnixTime: now,
})
transactionTagIdsMap[tagId] = true
}
}
tagIndexUuids := s.GenerateUuids(uuid.UUID_TYPE_TAG_INDEX, uint16(len(transactionTagIndexes)))
if len(tagIndexUuids) < len(transactionTagIndexes) {
return errs.ErrCannotAddTagsToTooManyTransactionsOneTime
}
for i := 0; i < len(transactionTagIndexes); i++ {
transactionTagIndexes[i].TagIndexId = tagIndexUuids[i]
}
tagIds := make([]int64, 0, len(transactionTagIdsMap))
for tagId := range transactionTagIdsMap {
tagIds = append(tagIds, tagId)
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
// Get and verify tags
err := s.isTagsValid(sess, uid, transactionTagIndexes, tagIds)
if err != nil {
return err
}
for i := 0; i < len(transactionTagIndexes); i++ {
transactionTagIndex := transactionTagIndexes[i]
_, err := sess.Insert(transactionTagIndex)
if err != nil {
return err
}
}
return nil
})
}
// BatchRemoveTagsFromTransactions batch removes tags from transactions
func (s *TransactionService) BatchRemoveTagsFromTransactions(c core.Context, uid int64, transactionIds []int64, tagIds []int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
if len(transactionIds) < 1 {
return errs.ErrTransactionIdInvalid
}
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
uniqueTagIds := utils.ToUniqueInt64Slice(tagIds)
now := time.Now().Unix()
tagIndexUpdateModel := &models.TransactionTagIndex{
Deleted: true,
DeletedUnixTime: now,
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).In("tag_id", uniqueTagIds).Update(tagIndexUpdateModel)
if err != nil {
return err
} else if deletedRows < 1 {
return errs.ErrTransactionTagNotFound
}
return nil
})
}
// BatchClearAllTagsFromTransactions batch clears all tags from transactions
func (s *TransactionService) BatchClearAllTagsFromTransactions(c core.Context, uid int64, transactionIds []int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
if len(transactionIds) < 1 {
return errs.ErrTransactionIdInvalid
}
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
now := time.Now().Unix()
tagIndexUpdateModel := &models.TransactionTagIndex{
Deleted: true,
DeletedUnixTime: now,
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).Update(tagIndexUpdateModel)
if err != nil {
return err
} else if deletedRows < 1 {
return errs.ErrTransactionTagNotFound
}
return nil
})
}
// MoveAllTransactionsBetweenAccounts moves all transactions from one account to another account, and combine balance modification transactions if necessary
func (s *TransactionService) MoveAllTransactionsBetweenAccounts(c core.Context, uid int64, fromAccountId int64, toAccountId int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
@@ -1729,7 +2041,7 @@ func (s *TransactionService) DeleteAllTransactionsOfAccount(c core.Context, uid
return errs.ErrAccountIdInvalid
}
transactions, err := s.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{accountId}, nil, false, "", "", pageCount, true)
transactions, err := s.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{accountId}, nil, false, "", "", false, pageCount, true)
if err != nil {
return err
@@ -2236,7 +2548,7 @@ func (s *TransactionService) doCreateTransaction(c core.Context, database *datas
}
// Get and verify tags
err = s.isTagsValid(sess, transaction, transactionTagIndexes, tagIds)
err = s.isTagsValid(sess, transaction.Uid, transactionTagIndexes, tagIds)
if err != nil {
return err
@@ -2662,6 +2974,17 @@ func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Sessi
return sess
}
func (s *TransactionService) appendFilterPicturesConditionToQuery(sess *xorm.Session, uid int64, mustHavePictures bool) *xorm.Session {
if !mustHavePictures {
return sess
}
subQuery := builder.Select("transaction_id").From("transaction_picture_info").Where(builder.And(builder.Eq{"uid": uid}, builder.Eq{"deleted": false}, builder.Neq{"transaction_id": models.TransactionPictureNewPictureTransactionId}))
sess.And(builder.Or(builder.In("transaction_id", subQuery), builder.In("related_id", subQuery)))
return sess
}
func (s *TransactionService) isAccountIdValid(transaction *models.Transaction) error {
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
if transaction.RelatedAccountId != 0 && transaction.RelatedAccountId != transaction.AccountId {
@@ -2873,10 +3196,10 @@ func (s *TransactionService) isCategoryValid(sess *xorm.Session, transaction *mo
return nil
}
func (s *TransactionService) isTagsValid(sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64) error {
func (s *TransactionService) isTagsValid(sess *xorm.Session, uid int64, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64) error {
if len(transactionTagIndexes) > 0 {
var tags []*models.TransactionTag
err := sess.Where("uid=? AND deleted=?", transaction.Uid, false).In("tag_id", tagIds).Find(&tags)
err := sess.Where("uid=? AND deleted=?", uid, false).In("tag_id", tagIds).Find(&tags)
if err != nil {
return err
+6 -2
View File
@@ -234,7 +234,7 @@ func (s *UserService) CreateUser(c core.Context, user *models.User, noPassword b
}
// UpdateUser saves an existed user model to database
func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLanguage bool) (keyProfileUpdated bool, emailSetToUnverified bool, err error) {
func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLanguage bool, modifyUseLastReconciledTime bool) (keyProfileUpdated bool, emailSetToUnverified bool, err error) {
if user.Uid <= 0 {
return false, false, errs.ErrUserIdInvalid
}
@@ -277,7 +277,11 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa
updateCols = append(updateCols, "default_account_id")
}
if models.TRANSACTION_EDIT_SCOPE_NONE <= user.TransactionEditScope && user.TransactionEditScope <= models.TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER {
if modifyUseLastReconciledTime {
updateCols = append(updateCols, "use_last_reconciled_time")
}
if models.TRANSACTION_EDIT_SCOPE_NONE <= user.TransactionEditScope && user.TransactionEditScope <= models.TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER {
updateCols = append(updateCols, "transaction_edit_scope")
}
+2
View File
@@ -135,6 +135,7 @@ const (
NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
BankOfIsraelDataSource string = "bank_of_israel"
NationalBankOfKazakhstanDataSource string = "national_bank_of_kazakhstan"
CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
NorgesBankDataSource string = "norges_bank"
NationalBankOfPolandDataSource string = "national_bank_of_poland"
@@ -1196,6 +1197,7 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio
dataSource == NationalBankOfGeorgiaDataSource ||
dataSource == CentralBankOfHungaryDataSource ||
dataSource == BankOfIsraelDataSource ||
dataSource == NationalBankOfKazakhstanDataSource ||
dataSource == CentralBankOfMyanmarDataSource ||
dataSource == NorgesBankDataSource ||
dataSource == NationalBankOfPolandDataSource ||
+6
View File
@@ -286,6 +286,12 @@ func IsUnixTimeEqualsYearAndMonth(unixTime int64, timezone *time.Location, year
return date.Year() == int(year) && int(date.Month()) == int(month)
}
// GetMaxDayOfMonth returns the maximum day of the month for the specified year and month
func GetMaxDayOfMonth(year int, month time.Month) int {
t := time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC)
return t.Day()
}
// GetTimezoneOffsetMinutes returns offset minutes according specified timezone
func GetTimezoneOffsetMinutes(unixTime int64, timezone *time.Location) int16 {
_, tzOffset := parseFromUnixTime(unixTime).In(timezone).Zone()
+26
View File
@@ -333,6 +333,32 @@ func TestIsUnixTimeEqualsYearAndMonth(t *testing.T) {
assert.Equal(t, false, actualValue)
}
func TestGetMaxDayOfMonth(t *testing.T) {
expectedValue := 31
actualValue := GetMaxDayOfMonth(2023, 1)
assert.Equal(t, expectedValue, actualValue)
expectedValue = 28
actualValue = GetMaxDayOfMonth(2023, 2)
assert.Equal(t, expectedValue, actualValue)
expectedValue = 29
actualValue = GetMaxDayOfMonth(2024, 2)
assert.Equal(t, expectedValue, actualValue)
expectedValue = 30
actualValue = GetMaxDayOfMonth(2023, 4)
assert.Equal(t, expectedValue, actualValue)
expectedValue = 31
actualValue = GetMaxDayOfMonth(2023, 12)
assert.Equal(t, expectedValue, actualValue)
expectedValue = 28
actualValue = GetMaxDayOfMonth(2100, 2)
assert.Equal(t, expectedValue, actualValue)
}
func TestGetTimezoneOffsetMinutes_FixedTimezone(t *testing.T) {
timezone := time.FixedZone("Test Timezone", 120*60)
expectedValue := int16(120)
+7 -3
View File
@@ -292,7 +292,7 @@ $API_CONFIGS = @(
Path = "transactions/list.json"
RequiresTimezone = $true
RequiredParams = @("count")
OptionalParams = @("type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "max_time", "min_time", "page", "with_count", "with_pictures", "trim_account", "trim_category", "trim_tag")
OptionalParams = @("type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "must_have_pictures", "max_time", "min_time", "page", "with_count", "with_pictures", "trim_account", "trim_category", "trim_tag")
ParamTypes = @{
"count" = "integer"
"type" = "integer"
@@ -301,6 +301,7 @@ $API_CONFIGS = @(
"tag_filter" = "string"
"amount_filter" = "string"
"keyword" = "string"
"must_have_pictures" = "boolean"
"max_time" = "integer"
"min_time" = "integer"
"page" = "integer"
@@ -318,6 +319,7 @@ $API_CONFIGS = @(
"tag_filter" = "string (Filter by tags)"
"amount_filter" = "string (Filter by amount)"
"keyword" = "string (Filter by keyword)"
"must_have_pictures" = "boolean (Whether to only get transactions with pictures)"
"max_time" = "integer (The maximum time sequence ID, Set to 0 for latest)"
"min_time" = "integer (The minimum time sequence ID)"
"page" = "integer (Specified page integer)"
@@ -374,7 +376,7 @@ $API_CONFIGS = @(
Path = "transactions/list/all.json"
RequiresTimezone = $true
RequiredParams = @()
OptionalParams = @("type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "start_time", "end_time", "with_pictures", "trim_account", "trim_category", "trim_tag")
OptionalParams = @("type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "must_have_pictures", "start_time", "end_time", "with_pictures", "trim_account", "trim_category", "trim_tag")
ParamTypes = @{
"type" = "integer"
"category_ids" = "string"
@@ -382,6 +384,7 @@ $API_CONFIGS = @(
"tag_filter" = "string"
"amount_filter" = "string"
"keyword" = "string"
"must_have_pictures" = "boolean"
"start_time" = "integer"
"end_time" = "integer"
"with_pictures" = "boolean"
@@ -396,6 +399,7 @@ $API_CONFIGS = @(
"tag_filter" = "string (Filter by tags)"
"amount_filter" = "string (Filter by amount)"
"keyword" = "string (Filter by keyword)"
"must_have_pictures" = "boolean (Whether to only get transactions with pictures)"
"start_time" = "integer (Transaction list start unix time)"
"end_time" = "integer (Transaction list end unix time)"
"with_pictures" = "boolean (Whether to get picture IDs)"
@@ -1305,7 +1309,7 @@ function Parse-CommandArgs {
}
"boolean" {
if ($paramValue -match "^(true|false|1|0)$") {
$params[$paramName] = ($paramValue -eq "true" -or $paramValue -eq "1")
$params[$paramName] = ($paramValue -eq "true" -or $paramValue -eq "1").ToString().ToLower()
} else {
Write-Red "Error: Parameter '-$paramName' must be a boolean value (true/false or 1/0)"
exit 1
+17 -10
View File
@@ -272,7 +272,7 @@ API_CONFIGS='[
"Path": "transactions/list.json",
"RequiresTimezone": true,
"RequiredParams": ["count"],
"OptionalParams": ["type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "max_time", "min_time", "page", "with_count", "with_pictures", "trim_account", "trim_category", "trim_tag"],
"OptionalParams": ["type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "must_have_pictures", "max_time", "min_time", "page", "with_count", "with_pictures", "trim_account", "trim_category", "trim_tag"],
"ParamTypes": {
"count": "integer",
"type": "integer",
@@ -281,6 +281,7 @@ API_CONFIGS='[
"tag_filter": "string",
"amount_filter": "string",
"keyword": "string",
"must_have_pictures": "boolean",
"max_time": "integer",
"min_time": "integer",
"page": "integer",
@@ -298,6 +299,7 @@ API_CONFIGS='[
"tag_filter": "string (Filter by tags)",
"amount_filter": "string (Filter by amount)",
"keyword": "string (Filter by keyword)",
"must_have_pictures": "boolean (Whether to only get transactions with pictures)",
"max_time": "integer (The maximum time sequence ID, Set to 0 for latest)",
"min_time": "integer (The minimum time sequence ID)",
"page": "integer (Specified page integer)",
@@ -354,7 +356,7 @@ API_CONFIGS='[
"Path": "transactions/list/all.json",
"RequiresTimezone": true,
"RequiredParams": [],
"OptionalParams": ["type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "start_time", "end_time", "with_pictures", "trim_account", "trim_category", "trim_tag"],
"OptionalParams": ["type", "category_ids", "account_ids", "tag_filter", "amount_filter", "keyword", "must_have_pictures", "start_time", "end_time", "with_pictures", "trim_account", "trim_category", "trim_tag"],
"ParamTypes": {
"type": "integer",
"category_ids": "string",
@@ -362,6 +364,7 @@ API_CONFIGS='[
"tag_filter": "string",
"amount_filter": "string",
"keyword": "string",
"must_have_pictures": "boolean",
"start_time": "integer",
"end_time": "integer",
"with_pictures": "boolean",
@@ -376,6 +379,7 @@ API_CONFIGS='[
"tag_filter": "string (Filter by tags)",
"amount_filter": "string (Filter by amount)",
"keyword": "string (Filter by keyword)",
"must_have_pictures": "boolean (Whether to only get transactions with pictures)",
"start_time": "integer (Transaction list start unix time)",
"end_time": "integer (Transaction list end unix time)",
"with_pictures": "boolean (Whether to get picture IDs)",
@@ -578,8 +582,11 @@ load_env_file() {
value="$(echo "$value" | sed -e 's/^["'"'"']//' -e 's/["'"'"']$//')"
case "$key" in
EBKTOOL_SERVER_BASEURL|EBKTOOL_TOKEN)
eval "$key=\"\$value\""
EBKTOOL_SERVER_BASEURL)
EBKTOOL_SERVER_BASEURL="$value"
;;
EBKTOOL_TOKEN)
EBKTOOL_TOKEN="$value"
;;
esac
done < "$env_file"
@@ -1124,7 +1131,7 @@ call_api() {
if [ "$json_params" != "{}" ]; then
if [ -n "$timezone_headers" ]; then
response="$(curl -s -X "POST" \
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
-H "Authorization: Bearer $authToken" \
-H "Content-Type: application/json" \
-H "$timezone_headers" \
-d "$json_params" \
@@ -1132,7 +1139,7 @@ call_api() {
curl_exit_code=$?
else
response="$(curl -s -X "POST" \
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
-H "Authorization: Bearer $authToken" \
-H "Content-Type: application/json" \
-d "$json_params" \
"$url")"
@@ -1141,13 +1148,13 @@ call_api() {
else
if [ -n "$timezone_headers" ]; then
response="$(curl -s -X "POST" \
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
-H "Authorization: Bearer $authToken" \
-H "$timezone_headers" \
"$url")"
curl_exit_code=$?
else
response="$(curl -s -X "POST" \
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
-H "Authorization: Bearer $authToken" \
"$url")"
curl_exit_code=$?
fi
@@ -1162,12 +1169,12 @@ call_api() {
if [ -n "$timezone_headers" ]; then
response="$(curl -s -X "$method" \
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
-H "Authorization: Bearer $authToken" \
-H "$timezone_headers" \
"$url")"
else
response="$(curl -s -X "$method" \
-H "Authorization: Bearer $EBKTOOL_TOKEN" \
-H "Authorization: Bearer $authToken" \
"$url")"
fi
curl_exit_code=$?
@@ -1,4 +1,4 @@
import { computed } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
@@ -8,7 +8,8 @@ import {
type YearUnixTime,
type YearQuarterUnixTime,
type YearMonthUnixTime,
YearMonthDayUnixTime
YearMonthDayUnixTime,
DateRange
} from '@/core/datetime.ts';
import type { FiscalYearUnixTime } from '@/core/fiscalyear.ts';
import { ChartDateAggregationType } from '@/core/statistics.ts';
@@ -16,7 +17,11 @@ import type { AccountInfoResponse } from '@/models/account.ts';
import type { TransactionReconciliationStatementResponseItem } from '@/models/transaction.ts';
import { isArray } from '@/lib/common.ts';
import { sumAmounts } from '@/lib/numeral.ts';
import {
mean,
median,
percentile
} from '@/lib/math.ts';
import {
parseDateTimeFromUnixTime,
getGregorianCalendarYearAndMonthFromUnixTime,
@@ -24,11 +29,16 @@ import {
getQuarterFirstTimeTimeBySpecifiedUnixTime,
getMonthFirstDateTimeBySpecifiedUnixTime,
getDayFirstDateTimeBySpecifiedUnixTime,
getBillingCycleLastUnixTimeBySpecifiedUnixTime,
getAllDaysStartAndEndUnixTimes,
getAllBillingCyclesStartAndEndUnixTimes,
getFiscalYearStartDateTime
} from '@/lib/datetime.ts';
import { TimezoneTypeForStatistics } from '@/core/timezone.ts';
import { getAllDateRangesByYearMonthRange } from '@/lib/statistics.ts';
import {
getAllDateRangesByYearMonthRange,
getDateRangeKeyWithYearOffset
} from '@/lib/statistics.ts';
export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange {
minUnixTimeOpeningBalance: number;
@@ -37,7 +47,10 @@ export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange {
}
export interface AccountBalanceTrendsChartItem {
dateRangeKey: string;
lastYearDateRangeKey: string;
displayDate: string;
alternativeDisplayDate: string;
openingBalance: number;
closingBalance: number;
minimumBalance: number;
@@ -54,6 +67,7 @@ export interface CommonAccountBalanceTrendsChartProps {
timezoneUsedForDateRange: number;
fiscalYearStart: number;
account: AccountInfoResponse;
statementDate: number | undefined;
}
export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTrendsChartProps) {
@@ -62,9 +76,13 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
formatDateTimeToGregorianLikeShortYear,
formatDateTimeToGregorianLikeShortYearMonth,
formatDateTimeToGregorianLikeYearQuarter,
formatDateTimeToGregorianLikeFiscalYear
formatDateTimeToGregorianLikeFiscalYear,
formatDateRange
} = useI18n();
const showYearOverYearOnTooltip = ref<boolean>(true);
const showPeriodOverPeriodOnTooltip = computed<boolean>(() => props.dateAggregationType === ChartDateAggregationType.Day.type || props.dateAggregationType === ChartDateAggregationType.Month.type || props.dateAggregationType === ChartDateAggregationType.Quarter.type || props.dateAggregationType === ChartDateAggregationType.BillingCycle.type);
const dataDateRange = computed<AccountBalanceUnixTimeAndBalanceRange | null>(() => {
if (!props.items || props.items.length < 1) {
return null;
@@ -108,6 +126,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
if (props.dateAggregationType === ChartDateAggregationType.Day.type) {
return getAllDaysStartAndEndUnixTimes(dataDateRange.value.minUnixTime, dataDateRange.value.maxUnixTime);
} else if (props.dateAggregationType === ChartDateAggregationType.BillingCycle.type) {
return getAllBillingCyclesStartAndEndUnixTimes(dataDateRange.value.minUnixTime, dataDateRange.value.maxUnixTime, props.statementDate ?? 1);
} else {
const startYearMonth = getGregorianCalendarYearAndMonthFromUnixTime(dataDateRange.value.minUnixTime);
const endYearMonth = getGregorianCalendarYearAndMonthFromUnixTime(dataDateRange.value.maxUnixTime);
@@ -148,6 +168,9 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type) {
minDateTime = getDayFirstDateTimeBySpecifiedUnixTime(dateItem.time, transactionTimeUtfOffset);
displayDate = formatDateTimeToShortDate(minDateTime);
} else if (props.dateAggregationType === ChartDateAggregationType.BillingCycle.type) {
minDateTime = getBillingCycleLastUnixTimeBySpecifiedUnixTime(dateItem.time, props.statementDate ?? 1, transactionTimeUtfOffset);
displayDate = formatDateTimeToGregorianLikeShortYearMonth(minDateTime);
} else {
return ret;
}
@@ -171,6 +194,7 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
const minDateTime = parseDateTimeFromUnixTime(dateRange.minUnixTime);
let displayDate = '';
let alternativeDisplayDate = '';
if (props.dateAggregationType === ChartDateAggregationType.Year.type) {
displayDate = formatDateTimeToGregorianLikeShortYear(minDateTime);
@@ -182,11 +206,17 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
displayDate = formatDateTimeToGregorianLikeShortYearMonth(minDateTime);
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type) {
displayDate = formatDateTimeToShortDate(minDateTime);
} else if (props.dateAggregationType === ChartDateAggregationType.BillingCycle.type) {
const maxDateTime = parseDateTimeFromUnixTime(dateRange.maxUnixTime);
displayDate = formatDateTimeToGregorianLikeShortYearMonth(maxDateTime);
alternativeDisplayDate = formatDateRange(DateRange.Custom.type, dateRange.minUnixTime, dateRange.maxUnixTime);
} else {
return ret;
}
const dataItems = dayDataItemsMap[displayDate];
const dateRangeKey = getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType) ?? '';
const lastYearDateRangeKey = getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType, -1) ?? '';
if (isArray(dataItems)) {
if (dataItems.length < 1) {
@@ -205,12 +235,12 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
const openingBalance = dataItems[0]!.accountOpeningBalance;
const closingBalance = dataItems[dataItems.length - 1]!.accountClosingBalance;
const minimumBalance = Math.min(...dataItems.map(item => item.accountClosingBalance));
const maximumBalance = Math.max(...dataItems.map(item => item.accountClosingBalance));
const medianBalance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 2)]!.accountClosingBalance;
const averageBalance = Math.trunc(sumAmounts(dataItems.map(item => item.accountClosingBalance)) / dataItems.length);
const q1Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 4)]!.accountClosingBalance;
const q3Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length * 3 / 4)]!.accountClosingBalance;
const minimumBalance = allDataItemsSortedByClosingBalance[0]!.accountClosingBalance;
const maximumBalance = allDataItemsSortedByClosingBalance[allDataItemsSortedByClosingBalance.length - 1]!.accountClosingBalance;
const medianBalance = Math.trunc(median(allDataItemsSortedByClosingBalance, item => item.accountClosingBalance));
const averageBalance = Math.trunc(mean(dataItems, item => item.accountClosingBalance));
const q1Balance = Math.trunc(percentile(allDataItemsSortedByClosingBalance, 0.25, item => item.accountClosingBalance));
const q3Balance = Math.trunc(percentile(allDataItemsSortedByClosingBalance, 0.75, item => item.accountClosingBalance));
if (props.account.isAsset) {
lastOpeningBalance = openingBalance;
@@ -243,7 +273,10 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
}
ret.push({
dateRangeKey: dateRangeKey,
lastYearDateRangeKey: lastYearDateRangeKey,
displayDate: displayDate,
alternativeDisplayDate: alternativeDisplayDate,
openingBalance: lastOpeningBalance,
closingBalance: lastClosingBalance,
minimumBalance: lastMinimumBalance,
@@ -260,6 +293,18 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
return ret;
});
const allDataItemsMap = computed<Record<string, AccountBalanceTrendsChartItem>>(() => {
const ret: Record<string, AccountBalanceTrendsChartItem> = {};
for (const item of allDataItems.value) {
if (item.dateRangeKey) {
ret[item.dateRangeKey] = item;
}
}
return ret;
});
const allDisplayDateRanges = computed<string[]>(() => {
if (!allDataItems.value || allDataItems.value.length < 1) {
return [];
@@ -269,9 +314,13 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
});
return {
// states
showYearOverYearOnTooltip,
showPeriodOverPeriodOnTooltip,
// computed states
allDateRanges,
allDataItems,
allDataItemsMap,
allDisplayDateRanges
};
}
+14 -1
View File
@@ -32,6 +32,7 @@ export interface CommonPieChartProps {
colorField?: string;
hiddenField?: string;
amountValue?: boolean;
percentValue?: boolean;
defaultCurrency?: string;
showValue?: boolean;
showPercent?: boolean;
@@ -81,7 +82,7 @@ export function usePieChartBase(props: CommonPieChartProps) {
accumulatedPaintPercent += finalItem.paintPercent;
finalItem.displayPercent = formatPercentToLocalizedNumerals(finalItem.percent, 2, '<0.01');
finalItem.displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value, 2);
finalItem.displayValue = getDisplayValue(value);
validItems.push(finalItem);
}
@@ -94,6 +95,18 @@ export function usePieChartBase(props: CommonPieChartProps) {
return validItems;
});
function getDisplayValue(value: number): string {
if (props.percentValue) {
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
}
if (props.amountValue) {
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
}
return formatNumberToLocalizedNumerals(value, 2);
}
watch(() => props.items, () => {
selectedIndex.value = 0;
});
@@ -4,8 +4,11 @@ import { useI18n } from '@/locales/helpers.ts';
import { useUserStore } from '@/stores/user.ts';
import type { TypeAndDisplayName } from '@/core/base.ts';
import { type TypeAndDisplayName, itemAndIndex } from '@/core/base.ts';
import { type DateTime } from '@/core/datetime.ts';
import { sortNumbersArray } from '@/lib/common.ts';
import { getCurrentDateTime } from '@/lib/datetime.ts';
export interface CommonScheduleFrequencySelectionProps {
type: number;
@@ -15,29 +18,45 @@ export interface CommonScheduleFrequencySelectionProps {
label?: string;
}
export interface AvailableMonthDay {
day: number;
displayName: string;
}
export function useScheduleFrequencySelectionBase() {
const { getAllWeekDays, getAllTransactionScheduledFrequencyTypes, getMonthdayShortName } = useI18n();
const {
getAllWeekDays,
getAvailableMonthDays,
getAllTransactionScheduledFrequencyTypes,
formatDateTimeToLongMonthDay
} = useI18n();
const userStore = useUserStore();
const allTransactionScheduledFrequencyTypes = computed<TypeAndDisplayName[]>(() => getAllTransactionScheduledFrequencyTypes());
const allWeekDays = computed<TypeAndDisplayName[]>(() => getAllWeekDays(userStore.currentUserFirstDayOfWeek));
const allAvailableMonthDays = computed<AvailableMonthDay[]>(() => {
const allAvailableDays = [];
const allAvailableMonthDays = computed<TypeAndDisplayName[]>(() => getAvailableMonthDays(28, 3));
const allAvailableMonthAndDays = computed<TypeAndDisplayName[]>(() => {
const ret: TypeAndDisplayName[] = [];
const now: DateTime = getCurrentDateTime();
const maxDaysOfMonth: number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for (let i = 1; i <= 28; i++) {
allAvailableDays.push({
day: i,
displayName: getMonthdayShortName(i),
for (const [days, index] of itemAndIndex(maxDaysOfMonth)) {
const month = index + 1;
for (let day = 1; day <= days; day++) {
const dateTime = now.set({
month: month,
dayOfMonth: day,
hour: 0,
minute: 0,
second: 0,
millisecond: 0
});
ret.push({
type: month * 100 + day,
displayName: formatDateTimeToLongMonthDay(dateTime)
});
}
}
return allAvailableDays;
return ret;
});
function getFrequencyValues(value: string): number[] {
@@ -58,6 +77,7 @@ export function useScheduleFrequencySelectionBase() {
allTransactionScheduledFrequencyTypes,
allWeekDays,
allAvailableMonthDays,
allAvailableMonthAndDays,
// functions
getFrequencyValues
};
@@ -16,11 +16,11 @@ import {
import { useUserStore } from '@/stores/user.ts';
import { type NameValue, itemAndIndex } from '@/core/base.ts';
import { type NameNumeralValue, itemAndIndex } from '@/core/base.ts';
import { TextDirection } from '@/core/text.ts';
import type { ColorStyleValue } from '@/core/color.ts';
import { ThemeType } from '@/core/theme.ts';
import { AccountBalanceTrendChartType } from '@/core/statistics.ts';
import { AccountBalanceTrendChartType, ChartDateAggregationType } from '@/core/statistics.ts';
import { DEFAULT_CHART_COLORS } from '@/consts/color.ts';
import { isArray } from '@/lib/common.ts';
@@ -52,8 +52,19 @@ interface AccountBalanceTrendsChartDataItem {
const props = defineProps<DesktopAccountBalanceTrendsChartProps>();
const theme = useTheme();
const { tt, getCurrentLanguageTextDirection, formatAmountToLocalizedNumeralsWithCurrency } = useI18n();
const { allDataItems, allDisplayDateRanges } = useAccountBalanceTrendsChartBase(props);
const {
tt,
getCurrentLanguageTextDirection,
formatAmountToLocalizedNumeralsWithCurrency,
formatPercentToLocalizedNumerals
} = useI18n();
const {
showYearOverYearOnTooltip,
showPeriodOverPeriodOnTooltip,
allDataItems,
allDataItemsMap,
allDisplayDateRanges
} = useAccountBalanceTrendsChartBase(props);
const userStore = useUserStore();
@@ -189,105 +200,88 @@ const chartOptions = computed<object>(() => {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: (params: CallbackDataParams[]) => {
const dataIndex = params[0]!.dataIndex;
const dataItem: AccountBalanceTrendsChartItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
const yearOverYearDataItem: AccountBalanceTrendsChartItem | undefined = showYearOverYearOnTooltip.value ? allDataItemsMap.value[dataItem.lastYearDateRangeKey] : undefined;
const periodOverPeriodDataItem: AccountBalanceTrendsChartItem | undefined = showPeriodOverPeriodOnTooltip.value ? allDataItems.value[dataIndex - 1] : undefined;
let header: string = params[0]!.name;
let displayItems: NameNumeralValue[] = [];
let yearOverYearDataItemDisplayItems: NameNumeralValue[] | undefined = undefined;
let periodOverPeriodDataItemDisplayItems: NameNumeralValue[] | undefined = undefined;
let separatorLineIndex: number | undefined = undefined;
if (dataItem.alternativeDisplayDate) {
header = dataItem.alternativeDisplayDate;
}
if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
const dataIndex = params[0]!.dataIndex;
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
const displayItems: NameValue[] = [
{
name: tt('Minimum Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.minimumBalance, props.account.currency)
},
{
name: tt('Q1 Balance (First Quartile)'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q1Balance, props.account.currency)
},
{
name: tt('Median Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.medianBalance, props.account.currency)
},
{
name: tt('Q3 Balance (Third Quartile)'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q3Balance, props.account.currency)
},
{
name: tt('Maximum Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.maximumBalance, props.account.currency)
},
{
name: tt('Opening Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency)
},
{
name: tt('Closing Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.closingBalance, props.account.currency)
}
];
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
for (const [displayItem, index] of itemAndIndex(displayItems)) {
if (index === 5) {
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
}
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
+ `</div>`;
}
return tooltip;
header += ` ${props.legendName}`;
displayItems = getBoxplotChartTooltip(dataItem);
yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getBoxplotChartTooltip(yearOverYearDataItem) : undefined;
periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getBoxplotChartTooltip(periodOverPeriodDataItem) : undefined;
separatorLineIndex = 5;
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
const dataIndex = params[0]!.dataIndex;
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
const displayItems: NameValue[] = [
{
name: tt('Opening Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency)
},
{
name: tt('Closing Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.closingBalance, props.account.currency)
},
{
name: tt('Minimum Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.minimumBalance, props.account.currency)
},
{
name: tt('Maximum Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.maximumBalance, props.account.currency)
},
{
name: tt('Median Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.medianBalance, props.account.currency)
},
{
name: tt('Average Balance'),
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.averageBalance, props.account.currency)
header += ` ${props.legendName}`;
displayItems = getCandlestickChartTooltip(dataItem);
yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getCandlestickChartTooltip(yearOverYearDataItem) : undefined;
periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getCandlestickChartTooltip(periodOverPeriodDataItem) : undefined;
separatorLineIndex = 4;
} else {
displayItems = getDefaultChartTooltip(dataItem);
yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getDefaultChartTooltip(yearOverYearDataItem) : undefined;
periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getDefaultChartTooltip(periodOverPeriodDataItem) : undefined;
}
];
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
const totalColumnCount = 2 + (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length ? 1 : 0) + (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length ? 1 : 0);
let tooltip = `<table class="chart-tooltip-table"><tbody><tr><td colspan="2">${header}</td>`;
if (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length) {
tooltip += `<td><span class="ms-5" style="float: inline-end">${tt('Year-over-Year')}</span></td>`;
}
if (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length) {
let periodOverPeriodText = tt('Period-over-Period');
if (props.dateAggregationType === ChartDateAggregationType.Quarter.type) {
periodOverPeriodText = tt('Quarter-over-Quarter');
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type) {
periodOverPeriodText = tt('Month-over-Month');
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type) {
periodOverPeriodText = tt('Day-over-Day');
}
tooltip += `<td><span class="ms-5" style="float: inline-end">${periodOverPeriodText}</span></td>`;
}
tooltip += '</tr>';
for (const [displayItem, index] of itemAndIndex(displayItems)) {
if (index === 4) {
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
const displayValue = formatAmountToLocalizedNumeralsWithCurrency(displayItem.value, props.account.currency);
tooltip += `<tr><td><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
+ `<span>${displayItem.name}</span></td><td><span class="ms-5" style="float: inline-end">${displayValue}</span></td>`;
if (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length && yearOverYearDataItemDisplayItems[index]) {
const yearOverYearDisplayItem = yearOverYearDataItemDisplayItems[index];
const displayGrowthRate = formatDisplayChangeRate(displayItem.value, yearOverYearDisplayItem.value);
tooltip += `<td><span class="ms-5" style="float: inline-end">${displayGrowthRate}</span></td>`;
}
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
+ `</div>`;
if (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length && periodOverPeriodDataItemDisplayItems[index]) {
const periodOverPeriodDisplayItem = periodOverPeriodDataItemDisplayItems[index];
const displayGrowthRate = formatDisplayChangeRate(displayItem.value, periodOverPeriodDisplayItem.value);
tooltip += `<td><span class="ms-5" style="float: inline-end">${displayGrowthRate}</span></td>`;
}
tooltip += '</tr>';
if (separatorLineIndex !== undefined && index === separatorLineIndex - 1) {
tooltip += `<tr><td colspan="${totalColumnCount}" style="border-bottom: ${(isDarkMode.value ? '#eee' : '#333')} dashed 1px"></td></tr>`;
}
}
tooltip += `</tbody></table>`;
return tooltip;
} else {
const amount = params[0]!.data as number;
const value = formatAmountToLocalizedNumeralsWithCurrency(amount, props.account.currency);
return `${params[0]!.name}<br/>`
+ '<div><span class="chart-pointer" style="background-color: #' + DEFAULT_CHART_COLORS[0] + '"></span>'
+ `<span>${props.legendName}</span><span class="ms-5" style="float: inline-end">${value}</span>`
+ '</div>';
}
}
},
grid: {
@@ -332,6 +326,91 @@ const chartOptions = computed<object>(() => {
series: allSeries.value
};
});
function getBoxplotChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] {
return [
{
name: tt('Minimum Balance'),
value: dataItem.minimumBalance
},
{
name: tt('Q1 Balance (First Quartile)'),
value: dataItem.q1Balance
},
{
name: tt('Median Balance'),
value: dataItem.medianBalance
},
{
name: tt('Q3 Balance (Third Quartile)'),
value: dataItem.q3Balance
},
{
name: tt('Maximum Balance'),
value: dataItem.maximumBalance
},
{
name: tt('Opening Balance'),
value: dataItem.openingBalance
},
{
name: tt('Closing Balance'),
value: dataItem.closingBalance
}
];
}
function getCandlestickChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] {
return [
{
name: tt('Opening Balance'),
value: dataItem.openingBalance
},
{
name: tt('Closing Balance'),
value: dataItem.closingBalance
},
{
name: tt('Minimum Balance'),
value: dataItem.minimumBalance
},
{
name: tt('Maximum Balance'),
value: dataItem.maximumBalance
},
{
name: tt('Median Balance'),
value: dataItem.medianBalance
},
{
name: tt('Average Balance'),
value: dataItem.averageBalance
}
];
}
function getDefaultChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] {
return [
{
name: props.legendName,
value: dataItem.closingBalance
}
];
}
function formatDisplayChangeRate(current: number, reference: number): string {
if (reference === 0 && current === 0) {
return formatPercentToLocalizedNumerals(0, 2, '<0.01');
}
if (reference === 0) {
return '-';
}
const rate = (current - reference) / reference * 100;
return formatPercentToLocalizedNumerals(rate, 2, '<0.01');
}
</script>
<style scoped>
+3 -2
View File
@@ -81,8 +81,9 @@ import {
import { NumeralSystem, DecimalSeparator } from '@/core/numeral.ts';
import type { CurrencyPrependAndAppendText } from '@/core/currency.ts';
import { DEFAULT_DECIMAL_NUMBER_COUNT } from '@/consts/numeral.ts';
import { DEFAULT_DECIMAL_NUMBER_COUNT, AMOUNT_FACTOR } from '@/consts/numeral.ts';
import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '@/consts/transaction.ts';
import { isNumber, replaceAll } from '@/lib/common.ts';
import { evaluateExpressionToAmount } from '@/lib/evaluator.ts';
import type { ComponentDensity, InputVariant } from '@/lib/ui/desktop.ts';
@@ -297,7 +298,7 @@ function getFormattedValue(value: number): string {
function getDisplayCurrencyPrependAndAppendText(): CurrencyPrependAndAppendText | null {
const numericCurrentValue = parseAmountFromLocalizedNumerals(currentValue.value);
const isPlural = numericCurrentValue !== 100 && numericCurrentValue !== -100;
const isPlural = numericCurrentValue !== AMOUNT_FACTOR && numericCurrentValue !== -AMOUNT_FACTOR;
return getAmountPrependAndAppendText(props.currency, isPlural);
}
+5 -4
View File
@@ -70,6 +70,7 @@ const props = defineProps<{
displayOrdersField?: string;
translateName?: boolean;
amountValue?: boolean;
percentValue?: boolean;
defaultCurrency?: string;
enableClickItem?: boolean;
tooltipExtraColumnNames?: string[];
@@ -89,7 +90,7 @@ const {
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
formatAmountToLocalizedNumeralsWithCurrency,
formatNumberToLocalizedNumerals,
formatNumberToWesternArabicNumerals,
formatNumberToWesternArabicNumeralsWithoutDigitGrouping,
formatPercentToLocalizedNumerals
} = useI18n();
@@ -477,7 +478,7 @@ function getItemName(name: string): string {
}
function getDisplayValue(value: number): string {
if (props.oneHundredPercentStacked) {
if (props.oneHundredPercentStacked || props.percentValue) {
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
}
@@ -522,9 +523,9 @@ function exportData(): { headers: string[], data: string[][] } {
row.push(categoryName);
row.push(...allSeries.value.map(item => {
if (props.oneHundredPercentStacked) {
return formatNumberToWesternArabicNumerals(item.data[index] ?? 0);
return formatNumberToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0);
} else {
return formatAmountToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0);
return formatAmountToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0, props.defaultCurrency);
}
}));
data.push(row);
@@ -0,0 +1,268 @@
<template>
<v-chart autoresize :class="finalClass" :style="finalStyle" :option="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from 'vuetify';
import type { CallbackDataParams } from 'echarts/types/dist/shared';
import { useI18n } from '@/locales/helpers.ts';
import { useUserStore } from '@/stores/user.ts';
import { type WeekDayValue, KnownDateTimeFormat } from '@/core/datetime.ts';
import { ThemeType } from '@/core/theme.ts';
import {
isNumber,
getObjectOwnFieldCount,
mapObjectToArray
} from '@/lib/common.ts';
import { parseDateTimeFromKnownDateTimeFormat } from '@/lib/datetime.ts';
interface HeatMapData {
data: Record<number, YearlyHeatmapData>;
minValue: number;
maxValue: number;
}
interface YearlyHeatmapData {
gregorianYear: number;
displayYear: string;
data: [string, number][];
}
const props = defineProps<{
class?: string;
skeleton?: boolean;
showValue?: boolean;
items: Record<string, unknown>[];
idField: string;
valueField: string;
hiddenField?: string;
translateName?: boolean;
valueTypeName: string;
amountValue?: boolean;
percentValue?: boolean;
defaultCurrency?: string;
}>();
const theme = useTheme();
const {
tt,
getAllShortMonthNames,
getAllMinWeekdayNames,
formatDateTimeToLongDate,
getCalendarDisplayLongYearFromDateTime,
formatAmountToLocalizedNumeralsWithCurrency,
formatNumberToLocalizedNumerals,
formatPercentToLocalizedNumerals
} = useI18n();
const userStore = useUserStore();
const visualMapHeight: number = 100;
const calendarHeight: number = 180;
const calendarBottomMargin: number = 10;
const firstDayOfWeek = computed<WeekDayValue>(() => userStore.currentUserFirstDayOfWeek);
const dayNames = computed<string[]>(() => getAllMinWeekdayNames());
const monthNames = computed<string[]>(() => getAllShortMonthNames());
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const finalClass = computed<string>(() => {
let finalClass = '';
if (props.skeleton) {
finalClass += 'transition-in';
}
if (props.class) {
finalClass += ` ${props.class}`;
} else {
finalClass += ' calendar-heatmap-chart-container';
}
return finalClass;
});
const finalStyle = computed<Record<string, string>>(() => {
const style: Record<string, string> = {};
if (heatMapData.value.data) {
const calendarCount = getObjectOwnFieldCount(heatMapData.value.data);
style['height'] = `${visualMapHeight + calendarCount * calendarHeight + (calendarCount - 1) * calendarBottomMargin}px`;
}
return style;
});
const heatMapData = computed<HeatMapData>(() => {
const allData: Record<number, YearlyHeatmapData> = {};
let minValue: number = Number.POSITIVE_INFINITY;
let maxValue: number = 0;
for (const item of props.items) {
const id = getItemName(item[props.idField] as string);
const dateTime = parseDateTimeFromKnownDateTimeFormat(id, KnownDateTimeFormat.DefaultDate);
const value = item[props.valueField];
if (dateTime && isNumber(value) && (!props.hiddenField || !item[props.hiddenField])) {
if (value > maxValue) {
maxValue = value;
}
if (value < minValue) {
minValue = value;
}
const year: number = dateTime.getGregorianCalendarYear();
let data: YearlyHeatmapData | undefined = allData[year];
if (!data) {
data = {
gregorianYear: year,
displayYear: getCalendarDisplayLongYearFromDateTime(dateTime),
data: []
};
allData[year] = data;
}
data.data.push([dateTime.getGregorianCalendarYearDashMonthDashDay(), value]);
}
}
const ret: HeatMapData = {
data: allData,
minValue: minValue === Number.POSITIVE_INFINITY ? 0 : minValue,
maxValue: maxValue
};
return ret;
});
const chartOptions = computed<object>(() => {
return {
tooltip: {
backgroundColor: isDarkMode.value ? '#333' : '#fff',
borderColor: isDarkMode.value ? '#333' : '#fff',
textStyle: {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: (params: CallbackDataParams) => {
if (!props.showValue) {
return '';
}
const dataItem = params.data as [string, number];
const dateTime = dataItem && dataItem[0] ? parseDateTimeFromKnownDateTimeFormat(dataItem[0], KnownDateTimeFormat.DefaultDate) : '';
const name = props.valueTypeName;
const value = dataItem && isNumber(dataItem[1]) ? getDisplayValue(dataItem[1]) : '';
return (dateTime ? `<div class="d-inline-flex">${formatDateTimeToLongDate(dateTime)}</div><br/>` : '')
+ `<div><span class="chart-pointer" style="background-color: ${params.color}"></span>`
+ `<span>${name}</span>`
+ `<span class="ms-5">${value}</span>`
+ '</div>';
}
},
visualMap: [
{
type: 'continuous',
orient: 'horizontal',
top: 0,
left: 'center',
itemHeight: 320,
min: heatMapData.value.minValue,
max: heatMapData.value.maxValue,
calculable: true,
inRange: {
color: isDarkMode.value ? [ '#1a1a1a', '#c67e48' ] : [ '#faf8f4', '#c67e48' ]
},
textStyle: {
color: isDarkMode.value ? '#888' : '#666'
},
formatter: (value: string) => {
if (!props.showValue) {
return '';
}
return getDisplayValue(parseInt(value));
}
}
],
calendar: mapObjectToArray(heatMapData.value.data, (item, _, index) => {
return {
range: item.gregorianYear,
orient: 'horizontal',
left: 70,
top: visualMapHeight + index * (calendarHeight + calendarBottomMargin),
right: 20,
cellSize: ['auto', 20],
itemStyle: {
color: isDarkMode.value ? '#060504' : '#ffffff',
borderColor: isDarkMode.value ? '#4f4f4f' : '#e1e6f2'
},
splitLine: {
show: false
},
dayLabel: {
firstDay: firstDayOfWeek.value,
nameMap: dayNames.value,
color: isDarkMode.value ? '#888' : '#666'
},
monthLabel: {
nameMap: monthNames.value,
color: isDarkMode.value ? '#888' : '#666'
},
yearLabel: {
formatter: item.displayYear,
color: isDarkMode.value ? '#888' : '#666'
}
};
}),
series: mapObjectToArray(heatMapData.value.data, (item, _, index) => {
return {
type: 'heatmap',
animation: !props.skeleton,
coordinateSystem: 'calendar',
calendarIndex: index,
data: item.data,
label: {
show: false
},
emphasis: {
itemStyle: {
shadowBlur: 6,
shadowColor: isDarkMode.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'
}
}
};
})
};
});
function getItemName(name: string): string {
return props.translateName ? tt(name) : name;
}
function getDisplayValue(value: number): string {
if (props.percentValue) {
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
}
if (props.amountValue) {
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
}
return formatNumberToLocalizedNumerals(value, 2);
}
</script>
<style scoped>
.calendar-heatmap-chart-container {
width: 100%;
margin-top: 10px;
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-dialog persistent min-width="320" width="auto" v-model="showState">
<v-dialog persistent min-width="320" max-width="500" width="auto" v-model="showState">
<v-card>
<v-toolbar :color="finalColor">
<v-toolbar-title>{{ titleContent }}</v-toolbar-title>
+19 -2
View File
@@ -3,6 +3,7 @@
persistent-placeholder
:readonly="readonly"
:disabled="disabled"
:clearable="!emptyValue ? clearable : false"
:label="label"
:menu-props="{ contentClass: 'date-time-select-menu' }"
v-model="dateTime"
@@ -107,13 +108,16 @@ import { setChildInputFocus } from '@/lib/ui/desktop.ts';
const props = defineProps<{
modelValue: number;
timezoneUtcOffset: number;
emptyValue?: boolean;
disabled?: boolean;
readonly?: boolean;
clearable?: boolean;
label?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void;
(e: 'clear:modelValue'): void;
(e: 'error', message: string): void;
}>();
@@ -154,7 +158,12 @@ const dateTime = computed<Date>({
get: () => {
return getLocalDatetimeFromSameDateTimeOfUnixTime(props.modelValue, props.timezoneUtcOffset);
},
set: (value: Date) => {
set: (value: Date | null) => {
if (!value) {
emit('clear:modelValue');
return;
}
const unixTime = getUnixTimeFromSameDateTimeOfLocalDatetime(value, props.timezoneUtcOffset);
if (unixTime < 0) {
@@ -166,7 +175,7 @@ const dateTime = computed<Date>({
}
});
const displayTime = computed<string>(() => formatDateTimeToLongDateTime(parseDateTimeFromUnixTimeWithTimezoneOffset(props.modelValue, props.timezoneUtcOffset)));
const displayTime = computed<string>(() => props.emptyValue ? tt('None') : formatDateTimeToLongDateTime(parseDateTimeFromUnixTimeWithTimezoneOffset(props.modelValue, props.timezoneUtcOffset)));
const hourItems = computed<TimePickerValue[]>(() => generateAllHours(1, isHourTwoDigits.value));
const minuteItems = computed<TimePickerValue[]>(() => generateAllMinutesOrSeconds(1, isMinuteTwoDigits.value));
@@ -347,12 +356,20 @@ function onKeyDown(type: string, e: KeyboardEvent): void {
setChildInputFocus(minuteInput.value?.$el, 'input');
}, 50);
});
e.preventDefault();
e.stopPropagation();
return;
} else if (type === 'minute') {
nextTick(() => {
setTimeout(() => {
setChildInputFocus(secondInput.value?.$el, 'input');
}, 50);
});
e.preventDefault();
e.stopPropagation();
return;
}
}
+320
View File
@@ -0,0 +1,320 @@
<template>
<v-chart autoresize :class="finalClass" :style="finalStyle" :option="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from 'vuetify';
import type { CallbackDataParams } from 'echarts/types/dist/shared';
import { useI18n } from '@/locales/helpers.ts';
import { itemAndIndex } from '@/core/base.ts';
import { TextDirection } from '@/core/text.ts';
import { ThemeType } from '@/core/theme.ts';
import { isArray, isNumber } from '@/lib/common.ts';
interface HeatMapData {
allSeriesNames: string[];
data: [number, number, number][];
minValue: number;
maxValue: number;
}
const props = defineProps<{
class?: string;
skeleton?: boolean;
showValue?: boolean;
categoryTypeName: string;
allCategoryNames: string[];
items: Record<string, unknown>[];
nameField: string;
valuesField: string;
hiddenField?: string;
translateName?: boolean;
valueTypeName: string;
amountValue?: boolean;
percentValue?: boolean;
defaultCurrency?: string;
}>();
const theme = useTheme();
const {
tt,
getCurrentLanguageTextDirection,
formatAmountToLocalizedNumeralsWithCurrency,
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
formatNumberToLocalizedNumerals,
formatPercentToLocalizedNumerals
} = useI18n();
const textDirection = computed<TextDirection>(() => getCurrentLanguageTextDirection());
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const finalClass = computed<string>(() => {
let finalClass = '';
if (props.skeleton) {
finalClass += 'transition-in';
}
if (props.class) {
finalClass += ` ${props.class}`;
} else {
finalClass += ' heatmap-chart-container';
}
return finalClass;
});
const finalStyle = computed<Record<string, string>>(() => {
const style: Record<string, string> = {};
if (heatMapData.value.allSeriesNames && heatMapData.value.allSeriesNames.length > 15) {
style['height'] = `${heatMapData.value.allSeriesNames.length * 40}px`;
}
return style;
});
const heatMapData = computed<HeatMapData>(() => {
const allData: [number, number, number][] = [];
const allSeriesNames: string[] = [];
let minValue: number = Number.POSITIVE_INFINITY;
let maxValue: number = 0;
for (const [item, seriesIndex] of itemAndIndex(props.items)) {
if (props.hiddenField && item[props.hiddenField]) {
continue;
}
if (!isArray(item[props.valuesField])) {
continue;
}
allSeriesNames.push(getItemName(item[props.nameField] as string));
const allAmounts: number[] = item[props.valuesField] as number[];
for (const [amount, categoryIndex] of itemAndIndex(allAmounts)) {
if (amount > maxValue) {
maxValue = amount;
}
if (amount < minValue) {
minValue = amount;
}
allData.push([categoryIndex, seriesIndex, amount]);
}
}
const ret: HeatMapData = {
allSeriesNames: allSeriesNames,
data: allData,
minValue: minValue === Number.POSITIVE_INFINITY ? 0 : minValue,
maxValue: maxValue
};
return ret;
});
const yAxisWidth = computed<number>(() => {
let width: number = 60;
if (!heatMapData.value || !heatMapData.value.allSeriesNames) {
return width;
}
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context) {
context.font = '12px Arial';
for (const seriesName of heatMapData.value.allSeriesNames) {
const textMetrics = context.measureText(seriesName);
const actualWidth = Math.round(textMetrics.width) + 20;
if (actualWidth > width) {
width = actualWidth;
}
}
}
if (width >= 200) {
width = 200;
}
return width;
});
const chartOptions = computed<object>(() => {
return {
tooltip: {
backgroundColor: isDarkMode.value ? '#333' : '#fff',
borderColor: isDarkMode.value ? '#333' : '#fff',
textStyle: {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: (params: CallbackDataParams) => {
if (!props.showValue) {
return '';
}
const dataItem = params.data as [number, number, number];
const name = props.valueTypeName;
const value = dataItem && isNumber(dataItem[2]) ? getDisplayValue(dataItem[2]) : '';
return `<div class="d-inline-flex">${params.name}</div><br/>`
+ `<div><span class="chart-pointer" style="background-color: ${params.color}"></span>`
+ `<span>${name}</span>`
+ `<span class="ms-5">${value}</span>`
+ '</div>';
}
},
visualMap: [
{
type: 'continuous',
orient: 'horizontal',
top: 0,
left: 'center',
itemHeight: 320,
min: heatMapData.value.minValue,
max: heatMapData.value.maxValue,
calculable: true,
inRange: {
color: isDarkMode.value ? [ '#1a1a1a', '#c67e48' ] : [ '#faf8f4', '#c67e48' ]
},
textStyle: {
color: isDarkMode.value ? '#888' : '#666'
},
formatter: (value: string) => {
if (!props.showValue) {
return '';
}
return getDisplayValue(parseInt(value));
}
}
],
grid: {
left: yAxisWidth.value,
right: 20,
bottom: 40
},
xAxis: [
{
type: 'category',
data: props.allCategoryNames,
inverse: textDirection.value === TextDirection.RTL,
axisLabel: {
color: isDarkMode.value ? '#888' : '#666'
}
}
],
yAxis: [
{
type: 'category',
data: heatMapData.value.allSeriesNames,
inverse: true,
axisLabel: {
color: isDarkMode.value ? '#888' : '#666'
}
}
],
series: [
{
type: 'heatmap',
animation: !props.skeleton,
data: heatMapData.value.data,
label: {
show: props.showValue ?? false,
color: isDarkMode.value ? '#eee' : '#333',
formatter: (params: CallbackDataParams) => {
if (!props.showValue) {
return '';
}
const data: [number, number, number] = params.data as [number, number, number];
const value: number = data && isNumber(data[2]) ? data[2] : 0;
return getDisplayValue(value);
}
},
emphasis: {
itemStyle: {
shadowBlur: 6,
shadowColor: isDarkMode.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};
});
function getItemName(name: string): string {
return props.translateName ? tt(name) : name;
}
function getDisplayValue(value: number): string {
if (props.percentValue) {
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
}
if (props.amountValue) {
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
}
return formatNumberToLocalizedNumerals(value, 2);
}
function exportData(): { headers: string[], data: string[][] } {
const headers: string[] = [];
const data: string[][] = [];
headers.push(props.categoryTypeName);
for (const categoryName of props.allCategoryNames) {
headers.push(categoryName);
}
const allData: Record<string, number> = {};
for (const item of heatMapData.value.data) {
allData[`${item[0]}-${item[1]}`] = item[2];
}
for (const [seriesName, seriesKey] of itemAndIndex(heatMapData.value.allSeriesNames)) {
const row: string[] = [];
row.push(seriesName);
for (let categoryIndex = 0; categoryIndex < props.allCategoryNames.length; categoryIndex++) {
const value = allData[`${categoryIndex}-${seriesKey}`];
row.push(formatAmountToWesternArabicNumeralsWithoutDigitGrouping(value ?? 0, props.defaultCurrency));
}
data.push(row);
}
return {
headers: headers,
data: data
};
}
defineExpose({
exportData
});
</script>
<style scoped>
.heatmap-chart-container {
width: 100%;
height: 560px;
margin-top: 10px;
}
@media (min-width: 600px) {
.heatmap-chart-container {
height: 630px;
}
}
</style>
+259
View File
@@ -0,0 +1,259 @@
<template>
<v-chart autoresize :class="finalClass" :option="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from 'vuetify';
import type { CallbackDataParams } from 'echarts/types/dist/shared';
import { useI18n } from '@/locales/helpers.ts';
import { itemAndIndex } from '@/core/base.ts';
import type { ColorValue, ColorStyleValue } from '@/core/color.ts';
import { ThemeType } from '@/core/theme.ts';
import { DEFAULT_CHART_COLORS } from '@/consts/color.ts';
import { isArray, isString, isNumber } from '@/lib/common.ts';
import { getDisplayColor } from '@/lib/color.ts';
export type HierarchyChartDisplayType = 'treemap' | 'sunburst';
interface HierarchyDataItem {
name: string;
value: number;
children?: HierarchyDataItem[];
itemStyle: {
color: ColorStyleValue;
};
}
const props = defineProps<{
class?: string;
skeleton?: boolean;
type: HierarchyChartDisplayType;
showValue?: boolean;
categoryTypeName: string;
allCategoryNames: string[];
items: Record<string, unknown>[];
nameField: string;
valuesField: string;
colorField?: string;
hiddenField?: string;
translateName?: boolean;
amountValue?: boolean;
percentValue?: boolean;
defaultCurrency?: string;
}>();
const theme = useTheme();
const {
tt,
formatAmountToLocalizedNumeralsWithCurrency,
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
formatNumberToLocalizedNumerals,
formatPercentToLocalizedNumerals
} = useI18n();
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const finalClass = computed<string>(() => {
let finalClass = '';
if (props.skeleton) {
finalClass += 'transition-in';
}
if (props.class) {
finalClass += ` ${props.class}`;
} else {
finalClass += ' hierarchy-chart-container';
}
return finalClass;
});
const hierarchyData = computed<HierarchyDataItem[]>(() => {
const ret: HierarchyDataItem[] = [];
for (const [item, seriesIndex] of itemAndIndex(props.items)) {
if (props.hiddenField && item[props.hiddenField]) {
continue;
}
if (!isArray(item[props.valuesField])) {
continue;
}
const color: ColorStyleValue = getDisplayColor((props.colorField && item[props.colorField]) ? item[props.colorField] as ColorValue : DEFAULT_CHART_COLORS[seriesIndex % DEFAULT_CHART_COLORS.length]);
const hierarchyItem: HierarchyDataItem = {
name: getItemName(item[props.nameField] as string),
value: 0,
children: [],
itemStyle: {
color: color
}
};
const allAmounts: number[] = item[props.valuesField] as number[];
for (const [amount, categoryIndex] of itemAndIndex(allAmounts)) {
hierarchyItem.value += amount;
hierarchyItem.children?.push({
name: props.allCategoryNames[categoryIndex] ?? '',
value: amount,
itemStyle: {
color: color
}
});
}
ret.push(hierarchyItem);
}
return ret;
});
const chartOptions = computed<object>(() => {
const seriesOptions: Record<string, unknown> = {
type: props.type,
width: '100%',
height: '100%',
right: 20,
top: 0,
bottom: 20,
data: hierarchyData.value,
levels: [
{
itemStyle: {
gapWidth: 2
}
},
{
itemStyle: {
gapWidth: 1
}
}
],
animation: !props.skeleton,
nodeClick: false
};
if (props.type === 'treemap') {
seriesOptions['breadcrumb'] = {
show: false
};
} if (props.type === 'sunburst') {
seriesOptions['radius'] = [60, '95%'];
seriesOptions['itemStyle'] = {
borderRadius: 7,
borderWidth: 2
};
}
return {
tooltip: {
backgroundColor: isDarkMode.value ? '#333' : '#fff',
borderColor: isDarkMode.value ? '#333' : '#fff',
textStyle: {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: (params: CallbackDataParams & { treePathInfo: { name: string, value: number }[] }) => {
if (!props.showValue || !params.name) {
return '';
}
const rootValue = params.treePathInfo.length > 0 ? params.treePathInfo[0]?.value : 0;
const parentName = params.treePathInfo.length > 1 ? params.treePathInfo[params.treePathInfo.length - 2]?.name : undefined;
const parentValue = params.treePathInfo.length > 1 ? params.treePathInfo[params.treePathInfo.length - 2]?.value : undefined;
const parentDisplayValue = isNumber(parentValue) ? getDisplayValue(parentValue) : undefined;
const parentPercent = isNumber(parentValue) && isNumber(rootValue) && rootValue > 0 ? formatPercentToLocalizedNumerals(100.0 * parentValue / rootValue, 2, '<0.01') : undefined;
const name = params.name;
const displayValue = isNumber(params.value) ? getDisplayValue(params.value) : '';
const percent = isNumber(params.value) && isNumber(parentValue) && parentValue > 0 ? formatPercentToLocalizedNumerals(100.0 * params.value / parentValue, 2, '<0.01') : undefined;
let tooltip = `<tr><td><span class="chart-pointer" style="background-color: ${params.color}"></span><span>${name}</span></td>`
+ `<td><span class="ms-5">${displayValue}</span>`
+ (isString(percent) ? `<span class="ms-1">(${percent})</span>` : '')
+ `</td></tr>`;
if (isString(parentName) && isString(parentDisplayValue) && parentValue !== rootValue) {
tooltip = `<tr><td><span class="chart-pointer" style="background-color: ${params.color}"></span><span>${parentName}</span></td>`
+ `<td><span class="ms-5">${parentDisplayValue}</span>`
+ (isString(parentPercent) ? `<span class="ms-1">(${parentPercent})</span>` : '')
+ `</td></tr>`
+ tooltip;
}
tooltip = `<table class="chart-tooltip-table"><tbody>` + tooltip + `</tbody></table>`;
return tooltip;
}
},
series: [ seriesOptions ]
};
});
function getItemName(name: string): string {
return props.translateName ? tt(name) : name;
}
function getDisplayValue(value: number): string {
if (props.percentValue) {
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
}
if (props.amountValue) {
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
}
return formatNumberToLocalizedNumerals(value, 2);
}
function exportData(): { headers: string[], data: string[][] } {
const headers: string[] = [];
const data: string[][] = [];
headers.push(props.categoryTypeName);
for (const categoryName of props.allCategoryNames) {
headers.push(categoryName);
}
for (const item of hierarchyData.value) {
const row: string[] = [];
row.push(item.name);
for (const child of item.children ?? []) {
row.push(formatAmountToWesternArabicNumeralsWithoutDigitGrouping(child.value));
}
data.push(row);
}
return {
headers: headers,
data: data
};
}
defineExpose({
exportData
});
</script>
<style scoped>
.hierarchy-chart-container {
width: 100%;
height: 560px;
margin-top: 10px;
}
@media (min-width: 600px) {
.hierarchy-chart-container {
height: 630px;
}
}
</style>
+21 -15
View File
@@ -10,17 +10,17 @@
:disabled="disabled"
:icon="true"
:color="isActive ? 'primary' : 'default'"
@click="currentPage = parseInt(page)"
v-if="page !== '...'"
@click="currentPage = key;"
v-if="isNumber(key)"
>
<span>{{ getDisplayPage(page) }}</span>
<span>{{ formatNumberToLocalizedNumerals(key) }}</span>
</v-btn>
<v-btn variant="text"
color="default"
:density="density"
:disabled="disabled"
:icon="true"
v-if="page === '...'"
v-if="!isNumber(key)"
>
<span>{{ page }}</span>
<v-menu activator="parent"
@@ -30,12 +30,13 @@
<v-list>
<v-list-item class="text-sm" :density="density">
<v-list-item-title class="cursor-pointer">
<v-autocomplete width="100"
<v-autocomplete width="110"
item-title="name"
item-value="value"
auto-select-first="exact"
auto-select-first
:density="density"
:items="allPages"
:custom-filter="customFilter"
:no-data-text="tt('No results')"
v-model="currentPage"/>
</v-list-item-title>
@@ -49,11 +50,13 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { InternalItem, FilterMatch } from 'vuetify/lib/composables/filter.d.ts';
import { useI18n } from '@/locales/helpers.ts';
import { type NameNumeralValue, keys } from '@/core/base.ts';
import type { NumeralSystem } from '@/core/numeral.ts';
import { isNumber } from '@/lib/common.ts';
import type { ComponentDensity } from '@/lib/ui/desktop.ts';
const props = defineProps<{
@@ -68,21 +71,15 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: number): void;
}>();
const { tt, getCurrentNumeralSystemType } = useI18n();
const { tt, formatNumberToLocalizedNumerals } = useI18n();
const showMenus = ref<Record<string, boolean>>({});
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
function getDisplayPage(page: number | string): string {
return numeralSystem.value.replaceWesternArabicDigitsToLocalizedDigits(page.toString());
}
const allPages = computed<NameNumeralValue[]>(() => {
const pages: NameNumeralValue[] = [];
for (let i = 1; i <= props.totalPageCount; i++) {
pages.push({ value: i, name: getDisplayPage(i) });
pages.push({ value: i, name: formatNumberToLocalizedNumerals(i) });
}
return pages;
@@ -100,4 +97,13 @@ const currentPage = computed<number>({
}
}
});
function customFilter(value: string, query: string, item?: InternalItem): FilterMatch {
if (!item) {
return false;
}
const page = item.value as number;
return page.toString(10).includes(query);
}
</script>
+14 -1
View File
@@ -38,6 +38,7 @@ const props = defineProps<{
colorField?: string;
hiddenField?: string;
amountValue?: boolean;
percentValue?: boolean;
defaultCurrency?: string;
showValue?: boolean;
showPercent?: boolean;
@@ -84,7 +85,7 @@ const radarData = computed<RadarChartData>(() => {
const finalPercent = (isNumber(percent) && percent >= 0) ? percent : (value > 0 ? value / totalValidValue * 100 : 0);
const displayPercent = formatPercentToLocalizedNumerals(finalPercent, 2, '<0.01');
const displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value, 2);
const displayValue = getDisplayValue(value);
indicators.push({
name: name,
@@ -189,6 +190,18 @@ const chartOptions = computed<object>(() => {
] : []
};
});
function getDisplayValue(value: number): string {
if (props.percentValue) {
return formatPercentToLocalizedNumerals(value, 2, '<0.01');
}
if (props.amountValue) {
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
}
return formatNumberToLocalizedNumerals(value, 2);
}
</script>
<style scoped>
@@ -28,25 +28,42 @@
<v-list v-if="frequencyType === ScheduledTemplateFrequencyType.Disabled.type">
<v-list-item :title="tt('None')"></v-list-item>
</v-list>
<v-list v-if="frequencyType === ScheduledTemplateFrequencyType.Daily.type">
<v-list-item :title="tt('Daily')"></v-list-item>
</v-list>
<v-list select-strategy="classic" v-model:selected="frequencyValue"
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Weekly.type">
<v-list-item :key="weekDay.type" :value="weekDay.type" :title="weekDay.displayName"
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(weekDay.type) }"
v-for="weekDay in allWeekDays">
<template #prepend="{ isActive }">
<v-checkbox density="compact" class="me-1" :model-value="isActive"
@update:model-value="updateFrequencyValue(weekDay.type, $event)"></v-checkbox>
<template #prepend="{ isSelected, select }">
<v-list-item-action start>
<v-checkbox-btn density="compact" :model-value="isSelected" @update:model-value="select"></v-checkbox-btn>
</v-list-item-action>
</template>
</v-list-item>
</v-list>
<v-list select-strategy="classic" v-model:selected="frequencyValue"
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Monthly.type">
<v-list-item :key="monthDay.day" :value="monthDay.day" :title="monthDay.displayName"
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(monthDay.day) }"
<v-list-item :key="monthDay.type" :value="monthDay.type" :title="monthDay.displayName"
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(monthDay.type) }"
v-for="monthDay in allAvailableMonthDays">
<template #prepend="{ isActive }">
<v-checkbox density="compact" class="me-1" :model-value="isActive"
@update:model-value="updateFrequencyValue(monthDay.day, $event)"></v-checkbox>
<template #prepend="{ isSelected, select }">
<v-list-item-action start>
<v-checkbox-btn density="compact" :model-value="isSelected" @update:model-value="select"></v-checkbox-btn>
</v-list-item-action>
</template>
</v-list-item>
</v-list>
<v-list select-strategy="classic" v-model:selected="frequencyValue"
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Yearly.type">
<v-list-item :key="monthAndDay.type" :value="monthAndDay.type" :title="monthAndDay.displayName"
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(monthAndDay.type) }"
v-for="monthAndDay in allAvailableMonthAndDays">
<template #prepend="{ isSelected, select }">
<v-list-item-action start>
<v-checkbox-btn density="compact" :model-value="isSelected" @update:model-value="select"></v-checkbox-btn>
</v-list-item-action>
</template>
</v-list-item>
</v-list>
@@ -75,8 +92,19 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const { tt, getMultiMonthdayShortNames, getMultiWeekdayLongNames } = useI18n();
const { allTransactionScheduledFrequencyTypes, allWeekDays, allAvailableMonthDays, getFrequencyValues } = useScheduleFrequencySelectionBase();
const {
tt,
getMultiMonthAndDayLongNames,
getMultiMonthdayShortNames,
getMultiWeekdayLongNames
} = useI18n();
const {
allTransactionScheduledFrequencyTypes,
allWeekDays,
allAvailableMonthDays,
allAvailableMonthAndDays,
getFrequencyValues
} = useScheduleFrequencySelectionBase();
const userStore = useUserStore();
@@ -92,10 +120,14 @@ const frequencyType = computed<number>({
if (props.type !== value) {
emit('update:type', value);
if (value === ScheduledTemplateFrequencyType.Weekly.type) {
if (value === ScheduledTemplateFrequencyType.Daily.type) {
frequencyValue.value = [0];
} else if (value === ScheduledTemplateFrequencyType.Weekly.type) {
frequencyValue.value = [firstDayOfWeek.value];
} else if (value === ScheduledTemplateFrequencyType.Monthly.type) {
frequencyValue.value = [1];
} else if (value === ScheduledTemplateFrequencyType.Yearly.type) {
frequencyValue.value = [101];
} else {
frequencyValue.value = [];
}
@@ -113,6 +145,8 @@ const frequencyValue = computed<number[]>({
const displayFrequency = computed<string>(() => {
if (frequencyType.value === ScheduledTemplateFrequencyType.Disabled.type) {
return tt('Disabled');
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Daily.type) {
return tt('Daily');
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Weekly.type) {
if (frequencyValue.value.length) {
return tt('format.misc.everyMultiDaysOfWeek', {
@@ -129,28 +163,19 @@ const displayFrequency = computed<string>(() => {
} else {
return tt('Monthly');
}
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Yearly.type) {
if (frequencyValue.value.length) {
return tt('format.misc.everyMultiDaysOfYear', {
days: getMultiMonthAndDayLongNames(frequencyValue.value)
});
} else {
return tt('Yearly');
}
} else {
return '';
}
});
function updateFrequencyValue(value: number, selected: boolean | null): void {
const currentFrequencyValues = frequencyValue.value;
const newFrequencyValues: number[] = [];
for (const currentFrequencyValue of currentFrequencyValues) {
if (currentFrequencyValue !== value || selected) {
newFrequencyValues.push(currentFrequencyValue);
}
}
if (selected) {
newFrequencyValues.push(value);
}
frequencyValue.value = sortNumbersArray(newFrequencyValues);
}
function isFrequencyValueSelected(currentValue: number): boolean {
return frequencyValue.value.indexOf(currentValue) >= 0;
}
+14 -11
View File
@@ -64,6 +64,9 @@ import {
getDateTypeByDateRange,
getFiscalYearFromUnixTime
} from '@/lib/datetime.ts';
import {
getDateRangeKeyWithYearOffset
} from '@/lib/statistics.ts';
type AxisChartType = InstanceType<typeof AxisChart>;
@@ -118,8 +121,16 @@ const allTooltipExtraColumnNames = computed<string[]>(() => {
}
if (props.showPeriodOverPeriod) {
if (props.dateAggregationType === ChartDateAggregationType.Quarter.type) {
extraColumnNames.push(tt('Quarter-over-Quarter'));
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type) {
extraColumnNames.push(tt('Month-over-Month'));
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && props.chartMode === 'daily') {
extraColumnNames.push(tt('Day-over-Day'));
} else {
extraColumnNames.push(tt('Period-over-Period'));
}
}
return extraColumnNames;
});
@@ -297,19 +308,11 @@ function getSeriesId(item: Record<string, unknown>): string {
}
function getDateRangeKey(dateRange: YearUnixTime | FiscalYearUnixTime | YearQuarterUnixTime | YearMonthUnixTime | YearMonthDayUnixTime, yearOffset?: number): string | undefined {
if (props.dateAggregationType === ChartDateAggregationType.Year.type) {
return (dateRange.year + (yearOffset ?? 0)).toString();
} else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) {
return (dateRange.year + (yearOffset ?? 0)).toString();
} else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.quarter}`;
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month0base' in dateRange) {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month0base + 1}`;
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && 'day' in dateRange && props.chartMode === 'daily') {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month}-${dateRange.day}`;
} else {
if (props.dateAggregationType === ChartDateAggregationType.Day.type && props.chartMode !== 'daily') {
return undefined;
}
return getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType, yearOffset);
}
function formatDisplayChangeRate(current: number, reference: number): string {
@@ -27,7 +27,7 @@
:key="item.index"
:style="`top: ${virtualDataItems.topPosition}px`"
:virtual-list-index="item.index"
:title="item.displayDate"
:title="item.alternativeDisplayDate || item.displayDate"
:after="formatAmountToLocalizedNumeralsWithCurrency(item.closingBalance, account.currency)"
v-for="item in virtualDataItems.items"
>
@@ -96,7 +96,10 @@ const allVirtualListItems = computed<MobileAccountBalanceTrendsChartItem[]>(() =
const finalDataItem: MobileAccountBalanceTrendsChartItem = {
index: index,
dateRangeKey: dataItem.dateRangeKey,
lastYearDateRangeKey: dataItem.lastYearDateRangeKey,
displayDate: dataItem.displayDate,
alternativeDisplayDate: dataItem.alternativeDisplayDate,
openingBalance: dataItem.openingBalance,
closingBalance: dataItem.closingBalance,
medianBalance: dataItem.medianBalance,
@@ -4,7 +4,8 @@
<f7-toolbar class="toolbar-with-swipe-handler">
<div class="swipe-handler"></div>
<div class="left">
<f7-link :text="tt('Now')" @click="setCurrentTime"></f7-link>
<f7-link :text="tt('Clear')" @click="clear" v-if="clearable"></f7-link>
<f7-link :text="tt('Now')" @click="setCurrentTime" v-if="!clearable"></f7-link>
</div>
<div class="right">
<f7-link :icon-f7="mode === 'time' ? 'calendar' : 'clock'" @click="switchMode"></f7-link>
@@ -122,11 +123,13 @@ const props = defineProps<{
modelValue: number;
timezoneUtcOffset: number;
initMode?: string;
clearable?: boolean;
show: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void;
(e: 'clear:modelValue'): void;
(e: 'update:show', value: boolean): void;
}>();
@@ -221,6 +224,11 @@ function setCurrentTime(): void {
}
}
function clear(): void {
emit('clear:modelValue');
emit('update:show', false);
}
function confirm(): void {
if (!dateTime.value) {
return;
+97
View File
@@ -0,0 +1,97 @@
<template>
<div :class="imageBoxClass" :style="style">
<img class="image-with-placeholder" :class="{ 'image-loading': loading }"
:src="src" :alt="alt" v-if="!link && !loadError"
@load="onLoad" @error="onError"/>
<f7-link class="image-link" :class="{ 'image-loading': loading }"
:href="link" v-if="link && !loadError">
<img class="image-with-placeholder" :src="src" :alt="alt"
@load="onLoad" @error="onError"/>
</f7-link>
<div class="image-loading-hint" v-if="loading && !loadError">
<f7-preloader size="28" />
</div>
<div class="image-error-hint" v-if="!link && !loading && loadError">
<slot name="error"></slot>
</div>
<f7-link class="image-error-hint" :href="link" v-if="link && !loading && loadError">
<slot name="error"></slot>
</f7-link>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
const props = defineProps<{
src: string;
class?: string;
style?: string
alt?: string;
link?: string;
}>();
const loading = ref<boolean>(true);
const loadError = ref<boolean>(false);
const imageBoxClass = computed<string>(() => {
let classes = 'image-box';
if (props.class) {
classes += ` ${props.class}`;
}
return classes;
});
function onLoad(): void {
loading.value = false;
}
function onError(): void {
loading.value = false;
loadError.value = true;
}
watch(() => props.src, () => {
loading.value = true;
loadError.value = false;
});
</script>
<style scoped>
.image-box > .image-with-placeholder,
.image-box > .image-link,
.image-box > .image-link > .image-with-placeholder {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.image-box > .image-with-placeholder.image-loading,
.image-box > .image-link.image-loading {
display: none !important;
}
.image-box > .image-loading-hint {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.image-box > .image-error-hint {
display: flex;
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
text-align: center;
font-size: var(--f7-list-item-footer-font-size);
color: var(--f7-list-item-footer-text-color);
padding: 4px;
overflow: hidden;
}
</style>
+27 -18
View File
@@ -18,52 +18,54 @@
</f7-popover>
<div class="numpad-buttons">
<f7-button class="numpad-button numpad-button-num" @click="inputNum(7)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(7)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[7] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(8)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(8)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[8] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(9)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(9)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[9] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('×')">
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerup="setSymbol('×')">
<span class="numpad-button-text numpad-button-text-normal">&times;</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(4)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(4)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[4] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(5)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(5)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[5] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(6)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(6)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[6] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('')">
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerup="setSymbol('')">
<span class="numpad-button-text numpad-button-text-normal">&minus;</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(1)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(1)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[1] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(2)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(2)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[2] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(3)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(3)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[3] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('+')">
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerup="setSymbol('+')">
<span class="numpad-button-text numpad-button-text-normal">&plus;</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" v-if="supportDecimalSeparator" @click="inputDecimalSeparator()">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputDecimalSeparator()"
v-if="supportDecimalSeparator">
<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)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputDoubleNum(0)"
v-if="!supportDecimalSeparator">
<span class="numpad-button-text numpad-button-text-normal">{{ `${digits[0]}${digits[0]}` }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="inputNum(0)">
<f7-button class="numpad-button numpad-button-num" @pointerup="inputNum(0)">
<span class="numpad-button-text numpad-button-text-normal">{{ digits[0] }}</span>
</f7-button>
<f7-button class="numpad-button numpad-button-num" @click="backspace" @taphold="clear()">
<f7-button class="numpad-button numpad-button-num" @pointerup="backspace" @taphold="clear()">
<span class="numpad-button-text numpad-button-text-normal">
<f7-icon class="icon-with-direction" f7="delete_left"></f7-icon>
</span>
@@ -83,6 +85,7 @@ import { useI18n } from '@/locales/helpers.ts';
import { useI18nUIComponents, isiOS } from '@/lib/ui/mobile.ts';
import { NumeralSystem } from '@/core/numeral.ts';
import { AMOUNT_FACTOR } from '@/consts/numeral.ts';
import { ALL_CURRENCIES } from '@/consts/currency.ts';
import { isNumber } from '@/lib/common.ts';
import logger from '@/lib/logger.ts';
@@ -385,7 +388,7 @@ function confirm(): boolean {
finalValue = previous - current;
break;
case '×':
finalValue = Math.trunc(previous * current / 100);
finalValue = Math.trunc(previous * current / AMOUNT_FACTOR);
break;
default:
finalValue = previous;
@@ -511,13 +514,19 @@ watch(() => props.flipNegative, (newValue) => {
align-items: center;
box-sizing: border-box;
user-select: none;
touch-action: none;
touch-action: manipulation;
transition: transform 0.01s ease;
}
.numpad-button-num {
width: calc(80% / 3);
}
.numpad-button-num:active,
.numpad-button-function:active {
background-color: var(--f7-button-pressed-bg-color, rgba(var(--f7-theme-color-rgb), 0.15));
}
.numpad-button-function, .numpad-button-confirm {
width: 20%;
}
@@ -33,6 +33,10 @@
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Disabled.type">
<f7-list-item :title="tt('None')"></f7-list-item>
</f7-list>
<f7-list dividers class="schedule-frequency-value-list no-margin-vertical"
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Daily.type">
<f7-list-item :title="tt('Daily')"></f7-list-item>
</f7-list>
<f7-list dividers class="schedule-frequency-value-list no-margin-vertical"
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Weekly.type">
<f7-list-item checkbox
@@ -48,15 +52,27 @@
<f7-list dividers class="schedule-frequency-value-list no-margin-vertical"
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Monthly.type">
<f7-list-item checkbox
:class="isChecked(monthDay.day) ? 'list-item-selected' : ''"
:key="monthDay.day"
:value="monthDay.day"
:checked="isChecked(monthDay.day)"
:class="isChecked(monthDay.type) ? 'list-item-selected' : ''"
:key="monthDay.type"
:value="monthDay.type"
:checked="isChecked(monthDay.type)"
:title="monthDay.displayName"
v-for="monthDay in allAvailableMonthDays"
@change="changeFrequencyValue">
</f7-list-item>
</f7-list>
<f7-list dividers class="schedule-frequency-value-list no-margin-vertical"
v-if="currentFrequencyType === ScheduledTemplateFrequencyType.Yearly.type">
<f7-list-item checkbox
:class="isChecked(monthAndDay.type) ? 'list-item-selected' : ''"
:key="monthAndDay.type"
:value="monthAndDay.type"
:checked="isChecked(monthAndDay.type)"
:title="monthAndDay.displayName"
v-for="monthAndDay in allAvailableMonthAndDays"
@change="changeFrequencyValue">
</f7-list-item>
</f7-list>
</div>
</div>
</div>
@@ -91,7 +107,13 @@ const emit = defineEmits<{
}>();
const { tt } = useI18n();
const { allTransactionScheduledFrequencyTypes, allWeekDays, allAvailableMonthDays, getFrequencyValues } = useScheduleFrequencySelectionBase();
const {
allTransactionScheduledFrequencyTypes,
allWeekDays,
allAvailableMonthDays,
allAvailableMonthAndDays,
getFrequencyValues
} = useScheduleFrequencySelectionBase();
const userStore = useUserStore();
@@ -108,10 +130,14 @@ function changeFrequencyType(value: number): void {
if (currentFrequencyType.value !== value) {
currentFrequencyType.value = value;
if (value === ScheduledTemplateFrequencyType.Weekly.type) {
if (value === ScheduledTemplateFrequencyType.Daily.type) {
currentFrequencyValue.value = [0];
} else if (value === ScheduledTemplateFrequencyType.Weekly.type) {
currentFrequencyValue.value = [firstDayOfWeek.value];
} else if (value === ScheduledTemplateFrequencyType.Monthly.type) {
currentFrequencyValue.value = [1];
} else if (value === ScheduledTemplateFrequencyType.Yearly.type) {
currentFrequencyValue.value = [101];
} else {
currentFrequencyValue.value = [];
}
+1
View File
@@ -7,6 +7,7 @@ export const DEFAULT_API_TIMEOUT: number = 10000; // 10s
export const DEFAULT_UPLOAD_API_TIMEOUT: number = 30000; // 30s
export const DEFAULT_EXPORT_API_TIMEOUT: number = 180000; // 180s
export const DEFAULT_IMPORT_API_TIMEOUT: number = 1800000; // 1800s
export const DEFAULT_BATCH_UPDATE_TRANSACTIONS_API_TIMEOUT: number = 1800000; // 1800s
export const DEFAULT_CLEAR_ALL_TRANSACTIONS_API_TIMEOUT: number = 1800000; // 1800s
export const DEFAULT_LLM_API_TIMEOUT: number = 600000; // 600s
+1
View File
@@ -2,6 +2,7 @@ import type { HiddenAmount } from '@/core/numeral.ts';
export const DEFAULT_DECIMAL_NUMBER_COUNT: number = 2;
export const MAX_SUPPORTED_DECIMAL_NUMBER_COUNT: number = 2;
export const AMOUNT_FACTOR: number = 10 ** MAX_SUPPORTED_DECIMAL_NUMBER_COUNT;
export const DISPLAY_HIDDEN_AMOUNT: HiddenAmount = '***';
export const INCOMPLETE_AMOUNT_SUFFIX: string = '+';
+3
View File
@@ -600,3 +600,6 @@ export const ALL_TIMEZONES: TimezoneInfo[] = [
timezoneName: 'Pacific/Kiritimati'
}
];
export const WESTERNMOST_TIMEZONE_UTC_OFFSET: number = -720; // Etc/GMT+12 (UTC-12:00)
export const EASTERNMOST_TIMEZONE_UTC_OFFSET: number = 840; // Pacific/Kiritimati (UTC+14:00)
+34 -23
View File
@@ -31,6 +31,7 @@ export interface DateTime {
isLocalizedCalendarFirstDayOfMonth(options: DateTimeFormatOptions): boolean;
getGregorianCalendarYearDashMonthDashDay(): TextualYearMonthDay;
getGregorianCalendarYearDashMonth(): TextualYearMonth;
getMaxDayOfGregorianCalendarMonth(): number;
getWeekDay(): WeekDay;
getWeekDayDisplayName(options: DateTimeFormatOptions): string
getWeekDayDisplayShortName(options: DateTimeFormatOptions): string;
@@ -700,49 +701,54 @@ export class DateRange implements TypeAndName {
private static readonly allInstancesByType: Record<number, DateRange> = {};
// All date range
public static readonly All = new DateRange(0, 'All', false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly All = new DateRange(0, 'All', false, false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
// Date ranges for normal scene only
public static readonly Today = new DateRange(1, 'Today', false, false, DateRangeScene.Normal, DateRangeScene.InsightsExplorer);
public static readonly Yesterday = new DateRange(2, 'Yesterday', false, false, DateRangeScene.Normal, DateRangeScene.InsightsExplorer);
public static readonly LastSevenDays = new DateRange(3, 'Recent 7 days', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastThirtyDays = new DateRange(4, 'Recent 30 days', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly ThisWeek = new DateRange(5, 'This week', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastWeek = new DateRange(6, 'Last week', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly ThisMonth = new DateRange(7, 'This month', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastMonth = new DateRange(8, 'Last month', false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly Today = new DateRange(1, 'Today', false, false, false, DateRangeScene.Normal, DateRangeScene.InsightsExplorer);
public static readonly Yesterday = new DateRange(2, 'Yesterday', false, false, false, DateRangeScene.Normal, DateRangeScene.InsightsExplorer);
public static readonly LastSevenDays = new DateRange(3, 'Recent 7 days', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastThirtyDays = new DateRange(4, 'Recent 30 days', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly ThisWeek = new DateRange(5, 'This week', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastWeek = new DateRange(6, 'Last week', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly ThisMonth = new DateRange(7, 'This month', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastMonth = new DateRange(8, 'Last month', false, false, false, DateRangeScene.Normal, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
// Date ranges for normal and trend analysis scene
public static readonly ThisYear = new DateRange(9, 'This year', false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastYear = new DateRange(10, 'Last year', false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly ThisFiscalYear = new DateRange(11, 'This fiscal year', false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastFiscalYear = new DateRange(12, 'Last fiscal year', false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly ThisYear = new DateRange(9, 'This year', false, false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastYear = new DateRange(10, 'Last year', false, false, false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly ThisFiscalYear = new DateRange(11, 'This fiscal year', false, false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly LastFiscalYear = new DateRange(12, 'Last fiscal year', false, false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
// Billing cycle date ranges for normal scene only
public static readonly CurrentBillingCycle = new DateRange(51, 'Current Billing Cycle', true, true, DateRangeScene.Normal);
public static readonly PreviousBillingCycle = new DateRange(52, 'Previous Billing Cycle', true, true, DateRangeScene.Normal);
public static readonly CurrentBillingCycle = new DateRange(51, 'Current Billing Cycle', true, false, true, DateRangeScene.Normal);
public static readonly PreviousBillingCycle = new DateRange(52, 'Previous Billing Cycle', true, false, true, DateRangeScene.Normal);
// Last reconciled time ranges for normal scene only
public static readonly SinceLastReconciledTime = new DateRange(71, 'Since Last Reconciled Time', false, true, true, DateRangeScene.Normal)
// Date ranges for trend analysis scene only
public static readonly RecentTwelveMonths = new DateRange(101, 'Recent 12 months', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentTwentyFourMonths = new DateRange(102, 'Recent 24 months', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentThirtySixMonths = new DateRange(103, 'Recent 36 months', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentTwoYears = new DateRange(104, 'Recent 2 years', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentThreeYears = new DateRange(105, 'Recent 3 years', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentFiveYears = new DateRange(106, 'Recent 5 years', false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentTwelveMonths = new DateRange(101, 'Recent 12 months', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentTwentyFourMonths = new DateRange(102, 'Recent 24 months', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentThirtySixMonths = new DateRange(103, 'Recent 36 months', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentTwoYears = new DateRange(104, 'Recent 2 years', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentThreeYears = new DateRange(105, 'Recent 3 years', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly RecentFiveYears = new DateRange(106, 'Recent 5 years', false, false, false, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
// Custom date range
public static readonly Custom = new DateRange(255, 'Custom Date', false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public static readonly Custom = new DateRange(255, 'Custom Date', false, false, true, DateRangeScene.Normal, DateRangeScene.TrendAnalysis, DateRangeScene.AssetTrends, DateRangeScene.InsightsExplorer);
public readonly type: number;
public readonly name: string;
public readonly isBillingCycle: boolean;
public readonly isLastReconciledTimeRange: boolean;
public readonly isUserCustomRange: boolean;
private readonly availableScenes: Record<number, boolean>;
private constructor(type: number, name: string, isBillingCycle: boolean, isUserCustomRange: boolean, ...availableScenes: DateRangeScene[]) {
private constructor(type: number, name: string, isBillingCycle: boolean, isLastReconciledTimeRange: boolean, isUserCustomRange: boolean, ...availableScenes: DateRangeScene[]) {
this.type = type;
this.name = name;
this.isBillingCycle = isBillingCycle;
this.isLastReconciledTimeRange = isLastReconciledTimeRange;
this.isUserCustomRange = isUserCustomRange;
this.availableScenes = {};
@@ -777,4 +783,9 @@ export class DateRange implements TypeAndName {
const dateRange = DateRange.allInstancesByType[type];
return dateRange?.isBillingCycle || false;
}
public static isLastReconciledTimeRange(type: number): boolean {
const dateRange = DateRange.allInstancesByType[type];
return dateRange?.isLastReconciledTimeRange || false;
}
}
+201 -102
View File
@@ -1,5 +1,6 @@
import { type NameValue } from '@/core/base.ts';
import { DateRange } from '@/core/datetime.ts';
import { ChartSortingType } from '@/core/statistics.ts';
export enum TransactionExplorerConditionRelation {
First = 'first',
@@ -25,6 +26,11 @@ export const TransactionExplorerConditionRelationPriority: Record<TransactionExp
export enum TransactionExplorerConditionFieldType {
Undefined = 'undefined',
TransactionTimeDayOfWeek = 'transactionTimeDayOfWeek',
TransactionTimeDayOfMonth = 'transactionTimeDayOfMonth',
TransactionTimeMonthOfYear = 'transactionTimeMonthOfYear',
TransactionTimeHourOfDay = 'transactionTimeHourOfDay',
TransactionTimezone = 'transactionTimezone',
TransactionType = 'transactionType',
TransactionCategory = 'transactionCategory',
SourceAccount = 'sourceAccount',
@@ -41,6 +47,11 @@ export class TransactionExplorerConditionField implements NameValue {
private static readonly allInstances: TransactionExplorerConditionField[] = [];
private static readonly allInstancesByValue: Record<string, TransactionExplorerConditionField> = {};
public static readonly TransactionTimeDayOfWeek = new TransactionExplorerConditionField('Transaction Day of Week', TransactionExplorerConditionFieldType.TransactionTimeDayOfWeek);
public static readonly TransactionTimeDayOfMonth = new TransactionExplorerConditionField('Transaction Day of Month', TransactionExplorerConditionFieldType.TransactionTimeDayOfMonth)
public static readonly TransactionTimeMonthOfYear = new TransactionExplorerConditionField('Transaction Month of Year', TransactionExplorerConditionFieldType.TransactionTimeMonthOfYear);
public static readonly TransactionTimeHourOfDay = new TransactionExplorerConditionField('Transaction Hour of Day', TransactionExplorerConditionFieldType.TransactionTimeHourOfDay);
public static readonly TransactionTimezone = new TransactionExplorerConditionField('Transaction Timezone', TransactionExplorerConditionFieldType.TransactionTimezone);
public static readonly TransactionType = new TransactionExplorerConditionField('Transaction Type', TransactionExplorerConditionFieldType.TransactionType);
public static readonly TransactionCategory = new TransactionExplorerConditionField('Category', TransactionExplorerConditionFieldType.TransactionCategory);
public static readonly SourceAccount = new TransactionExplorerConditionField('Source Account', TransactionExplorerConditionFieldType.SourceAccount);
@@ -74,6 +85,7 @@ export class TransactionExplorerConditionField implements NameValue {
export enum TransactionExplorerConditionOperatorType {
In = 'in',
NotIn = 'notIn',
GreaterThan = 'greaterThan',
LessThan = 'lessThan',
Equals = 'equals',
@@ -92,6 +104,10 @@ export enum TransactionExplorerConditionOperatorType {
NotStartsWith = 'notStartsWith',
EndsWith = 'endsWith',
NotEndsWith = 'notEndsWith',
RegexMatch = 'regexMatch',
NotRegexMatch = 'notRegexMatch',
MinuteOffsetBetween = 'minuteOffsetBetween',
MinuteOffsetNotBetween = 'minuteOffsetNotBetween',
LatitudeBetween = 'latitudeBetween',
LatitudeNotBetween = 'latitudeNotBetween',
LongitudeBetween = 'longitudeBetween',
@@ -103,6 +119,7 @@ export class TransactionExplorerConditionOperator implements NameValue {
private static readonly allInstancesByValue: Record<string, TransactionExplorerConditionOperator> = {};
public static readonly In = new TransactionExplorerConditionOperator('In', TransactionExplorerConditionOperatorType.In);
public static readonly NotIn = new TransactionExplorerConditionOperator('Not in', TransactionExplorerConditionOperatorType.NotIn);
public static readonly GreaterThan = new TransactionExplorerConditionOperator('Greater than', TransactionExplorerConditionOperatorType.GreaterThan);
public static readonly LessThan = new TransactionExplorerConditionOperator('Less than', TransactionExplorerConditionOperatorType.LessThan);
public static readonly Equals = new TransactionExplorerConditionOperator('Equal to', TransactionExplorerConditionOperatorType.Equals);
@@ -111,20 +128,24 @@ export class TransactionExplorerConditionOperator implements NameValue {
public static readonly NotBetween = new TransactionExplorerConditionOperator('Not between', TransactionExplorerConditionOperatorType.NotBetween);
public static readonly HasAny = new TransactionExplorerConditionOperator('Has any', TransactionExplorerConditionOperatorType.HasAny);
public static readonly HasAll = new TransactionExplorerConditionOperator('Has all', TransactionExplorerConditionOperatorType.HasAll);
public static readonly NotHasAny = new TransactionExplorerConditionOperator('Not has any', TransactionExplorerConditionOperatorType.NotHasAny);
public static readonly NotHasAll = new TransactionExplorerConditionOperator('Not has all', TransactionExplorerConditionOperatorType.NotHasAll);
public static readonly NotHasAny = new TransactionExplorerConditionOperator('Does not have any', TransactionExplorerConditionOperatorType.NotHasAny);
public static readonly NotHasAll = new TransactionExplorerConditionOperator('Does not have all', TransactionExplorerConditionOperatorType.NotHasAll);
public static readonly IsEmpty = new TransactionExplorerConditionOperator('Is empty', TransactionExplorerConditionOperatorType.IsEmpty);
public static readonly IsNotEmpty = new TransactionExplorerConditionOperator('Is not empty', TransactionExplorerConditionOperatorType.IsNotEmpty);
public static readonly Contains = new TransactionExplorerConditionOperator('Contains', TransactionExplorerConditionOperatorType.Contains);
public static readonly NotContains = new TransactionExplorerConditionOperator('Not contains', TransactionExplorerConditionOperatorType.NotContains);
public static readonly NotContains = new TransactionExplorerConditionOperator('Does not contain', TransactionExplorerConditionOperatorType.NotContains);
public static readonly StartsWith = new TransactionExplorerConditionOperator('Starts with', TransactionExplorerConditionOperatorType.StartsWith);
public static readonly NotStartsWith = new TransactionExplorerConditionOperator('Not starts with', TransactionExplorerConditionOperatorType.NotStartsWith);
public static readonly NotStartsWith = new TransactionExplorerConditionOperator('Does not start with', TransactionExplorerConditionOperatorType.NotStartsWith);
public static readonly EndsWith = new TransactionExplorerConditionOperator('Ends with', TransactionExplorerConditionOperatorType.EndsWith);
public static readonly NotEndsWith = new TransactionExplorerConditionOperator('Not ends with', TransactionExplorerConditionOperatorType.NotEndsWith);
public static readonly LatitudeBetween = new TransactionExplorerConditionOperator('Latitude between', TransactionExplorerConditionOperatorType.LatitudeBetween);
public static readonly LatitudeNotBetween = new TransactionExplorerConditionOperator('Latitude not between', TransactionExplorerConditionOperatorType.LatitudeNotBetween);
public static readonly LongitudeBetween = new TransactionExplorerConditionOperator('Longitude between', TransactionExplorerConditionOperatorType.LongitudeBetween);
public static readonly LongitudeNotBetween = new TransactionExplorerConditionOperator('Longitude not between', TransactionExplorerConditionOperatorType.LongitudeNotBetween);
public static readonly NotEndsWith = new TransactionExplorerConditionOperator('Does not end with', TransactionExplorerConditionOperatorType.NotEndsWith);
public static readonly RegexMatch = new TransactionExplorerConditionOperator('Matches regex', TransactionExplorerConditionOperatorType.RegexMatch);
public static readonly NotRegexMatch = new TransactionExplorerConditionOperator('Does not match regex', TransactionExplorerConditionOperatorType.NotRegexMatch);
public static readonly MinuteOffsetBetween = new TransactionExplorerConditionOperator('Minute offset is between', TransactionExplorerConditionOperatorType.MinuteOffsetBetween);
public static readonly MinuteOffsetNotBetween = new TransactionExplorerConditionOperator('Minute offset is not between', TransactionExplorerConditionOperatorType.MinuteOffsetNotBetween);
public static readonly LatitudeBetween = new TransactionExplorerConditionOperator('Latitude is between', TransactionExplorerConditionOperatorType.LatitudeBetween);
public static readonly LatitudeNotBetween = new TransactionExplorerConditionOperator('Latitude is not between', TransactionExplorerConditionOperatorType.LatitudeNotBetween);
public static readonly LongitudeBetween = new TransactionExplorerConditionOperator('Longitude is between', TransactionExplorerConditionOperatorType.LongitudeBetween);
public static readonly LongitudeNotBetween = new TransactionExplorerConditionOperator('Longitude is not between', TransactionExplorerConditionOperatorType.LongitudeNotBetween);
public readonly name: string;
public readonly value: TransactionExplorerConditionOperatorType;
@@ -146,56 +167,6 @@ export class TransactionExplorerConditionOperator implements NameValue {
}
}
export enum TransactionExplorerChartTypeValue {
Pie = 'pie',
ColumnStacked = 'columnStacked',
Column100PercentStacked = 'column100%Stacked',
ColumnGrouped = 'columnGrouped',
LineGrouped = 'lineGrouped',
AreaStacked = 'areaStacked',
Area100PercentStacked = 'area100%Stacked',
BubbleGrouped = 'bubbleGrouped',
Radar = 'radar'
}
export class TransactionExplorerChartType implements NameValue {
private static readonly allInstances: TransactionExplorerChartType[] = [];
private static readonly allInstancesByValue: Record<string, TransactionExplorerChartType> = {};
public static readonly Pie = new TransactionExplorerChartType('Pie Chart', TransactionExplorerChartTypeValue.Pie, false);
public static readonly Radar = new TransactionExplorerChartType('Radar Chart', TransactionExplorerChartTypeValue.Radar, false);
public static readonly ColumnStacked = new TransactionExplorerChartType('Column Chart (Stacked)', TransactionExplorerChartTypeValue.ColumnStacked, true);
public static readonly Column100PercentStacked = new TransactionExplorerChartType('Column Chart (100% Stacked)', TransactionExplorerChartTypeValue.Column100PercentStacked, true);
public static readonly ColumnGrouped = new TransactionExplorerChartType('Column Chart (Grouped)', TransactionExplorerChartTypeValue.ColumnGrouped, true);
public static readonly LineGrouped = new TransactionExplorerChartType('Line Chart (Grouped)', TransactionExplorerChartTypeValue.LineGrouped, true);
public static readonly AreaStacked = new TransactionExplorerChartType('Area Chart (Stacked)', TransactionExplorerChartTypeValue.AreaStacked, true);
public static readonly Area100PercentStacked = new TransactionExplorerChartType('Area Chart (100% Stacked)', TransactionExplorerChartTypeValue.Area100PercentStacked, true);
public static readonly BubbleGrouped = new TransactionExplorerChartType('Bubble Chart (Grouped)', TransactionExplorerChartTypeValue.BubbleGrouped, true);
public static readonly Default = TransactionExplorerChartType.Pie;
public readonly name: string;
public readonly value: TransactionExplorerChartTypeValue;
public readonly seriesDimensionRequired: boolean;
private constructor(name: string, value: TransactionExplorerChartTypeValue, seriesDimensionRequired: boolean) {
this.name = name;
this.value = value;
this.seriesDimensionRequired = seriesDimensionRequired;
TransactionExplorerChartType.allInstances.push(this);
TransactionExplorerChartType.allInstancesByValue[value] = this;
}
public static values(): TransactionExplorerChartType[] {
return TransactionExplorerChartType.allInstances;
}
public static valueOf(value: string): TransactionExplorerChartType | undefined {
return TransactionExplorerChartType.allInstancesByValue[value];
}
}
export enum TransactionExplorerDataDimensionType {
None = 'none',
Query = 'query',
@@ -218,51 +189,75 @@ export enum TransactionExplorerDataDimensionType {
DestinationAccount = 'destinationAccount',
DestinationAccountCategory = 'destinationAccountCategory',
DestinationAccountCurrency = 'destinationAccountCurrency',
PrimaryCategory = 'primaryCategory',
SecondaryCategory = 'secondaryCategory',
SourceAmount = 'sourceAmount',
DestinationAmount = 'destinationAmount',
PrimaryCategory = 'primaryCategory',
SecondaryCategory = 'secondaryCategory'
SourceAmountRangeEqualFrequency = 'sourceAmountRangeEqualFrequency',
SourceAmountRangeEqualWidth = 'sourceAmountRangeEqualWidth',
SourceAmountRangeLogScale = 'sourceAmountRangeLogScale',
SourceAmountRangeStandardDeviation = 'sourceAmountRangeStandardDeviation',
SourceAmountRangeNaturalBreaks = 'sourceAmountRangeNaturalBreaks',
DestinationAmountRangeEqualFrequency = 'destinationAmountRangeEqualFrequency',
DestinationAmountRangeEqualWidth = 'destinationAmountRangeEqualWidth',
DestinationAmountRangeLogScale = 'destinationAmountRangeLogScale',
DestinationAmountRangeStandardDeviation = 'destinationAmountRangeStandardDeviation',
DestinationAmountRangeNaturalBreaks = 'destinationAmountRangeNaturalBreaks'
}
export class TransactionExplorerDataDimension implements NameValue {
private static readonly allInstances: TransactionExplorerDataDimension[] = [];
private static readonly allInstancesByValue: Record<string, TransactionExplorerDataDimension> = {};
public static readonly None = new TransactionExplorerDataDimension('None', TransactionExplorerDataDimensionType.None);
public static readonly Query = new TransactionExplorerDataDimension('Query', TransactionExplorerDataDimensionType.Query);
public static readonly DateTime = new TransactionExplorerDataDimension('Transaction Time', TransactionExplorerDataDimensionType.DateTime);
public static readonly DateTimeByYearMonthDay = new TransactionExplorerDataDimension('Transaction Date', TransactionExplorerDataDimensionType.DateTimeByYearMonthDay);
public static readonly DateTimeByYearMonth = new TransactionExplorerDataDimension('Transaction Year-Month', TransactionExplorerDataDimensionType.DateTimeByYearMonth);
public static readonly DateTimeByYearQuarter = new TransactionExplorerDataDimension('Transaction Year-Quarter', TransactionExplorerDataDimensionType.DateTimeByYearQuarter);
public static readonly DateTimeByYear = new TransactionExplorerDataDimension('Transaction Year', TransactionExplorerDataDimensionType.DateTimeByYear);
public static readonly DateTimeByFiscalYear = new TransactionExplorerDataDimension('Transaction Fiscal Year', TransactionExplorerDataDimensionType.DateTimeByFiscalYear);
public static readonly DateTimeByDayOfWeek = new TransactionExplorerDataDimension('Transaction Day of Week', TransactionExplorerDataDimensionType.DateTimeByDayOfWeek);
public static readonly DateTimeByDayOfMonth = new TransactionExplorerDataDimension('Transaction Day of Month', TransactionExplorerDataDimensionType.DateTimeByDayOfMonth);
public static readonly DateTimeByMonthOfYear = new TransactionExplorerDataDimension('Transaction Month of Year', TransactionExplorerDataDimensionType.DateTimeByMonthOfYear);
public static readonly DateTimeByQuarterOfYear = new TransactionExplorerDataDimension('Transaction Quarter of Year', TransactionExplorerDataDimensionType.DateTimeByQuarterOfYear);
public static readonly DateTimeByHourOfDay = new TransactionExplorerDataDimension('Transaction Hour of Day', TransactionExplorerDataDimensionType.DateTimeByHourOfDay);
public static readonly TimezoneOffset = new TransactionExplorerDataDimension('Transaction Timezone', TransactionExplorerDataDimensionType.TimezoneOffset);
public static readonly TransactionType = new TransactionExplorerDataDimension('Transaction Type', TransactionExplorerDataDimensionType.TransactionType);
public static readonly SourceAccount = new TransactionExplorerDataDimension('Source Account', TransactionExplorerDataDimensionType.SourceAccount);
public static readonly SourceAccountCategory = new TransactionExplorerDataDimension('Source Account Category', TransactionExplorerDataDimensionType.SourceAccountCategory);
public static readonly SourceAccountCurrency = new TransactionExplorerDataDimension('Source Account Currency', TransactionExplorerDataDimensionType.SourceAccountCurrency);
public static readonly DestinationAccount = new TransactionExplorerDataDimension('Destination Account', TransactionExplorerDataDimensionType.DestinationAccount);
public static readonly DestinationAccountCategory = new TransactionExplorerDataDimension('Destination Account Category', TransactionExplorerDataDimensionType.DestinationAccountCategory);
public static readonly DestinationAccountCurrency = new TransactionExplorerDataDimension('Destination Account Currency', TransactionExplorerDataDimensionType.DestinationAccountCurrency);
public static readonly PrimaryCategory = new TransactionExplorerDataDimension('Primary Category', TransactionExplorerDataDimensionType.PrimaryCategory);
public static readonly SecondaryCategory = new TransactionExplorerDataDimension('Secondary Category', TransactionExplorerDataDimensionType.SecondaryCategory);
public static readonly SourceAmount = new TransactionExplorerDataDimension('Amount', TransactionExplorerDataDimensionType.SourceAmount);
public static readonly DestinationAmount = new TransactionExplorerDataDimension('Transfer In Amount', TransactionExplorerDataDimensionType.DestinationAmount);
public static readonly None = new TransactionExplorerDataDimension('None', TransactionExplorerDataDimensionType.None, false, false);
public static readonly Query = new TransactionExplorerDataDimension('Query', TransactionExplorerDataDimensionType.Query, false, false);
public static readonly DateTime = new TransactionExplorerDataDimension('Transaction Time', TransactionExplorerDataDimensionType.DateTime, false, false);
public static readonly DateTimeByYearMonthDay = new TransactionExplorerDataDimension('Transaction Date', TransactionExplorerDataDimensionType.DateTimeByYearMonthDay, false, false);
public static readonly DateTimeByYearMonth = new TransactionExplorerDataDimension('Transaction Year-Month', TransactionExplorerDataDimensionType.DateTimeByYearMonth, false, false);
public static readonly DateTimeByYearQuarter = new TransactionExplorerDataDimension('Transaction Year-Quarter', TransactionExplorerDataDimensionType.DateTimeByYearQuarter, false, false);
public static readonly DateTimeByYear = new TransactionExplorerDataDimension('Transaction Year', TransactionExplorerDataDimensionType.DateTimeByYear, false, false);
public static readonly DateTimeByFiscalYear = new TransactionExplorerDataDimension('Transaction Fiscal Year', TransactionExplorerDataDimensionType.DateTimeByFiscalYear, false, false);
public static readonly DateTimeByDayOfWeek = new TransactionExplorerDataDimension('Transaction Day of Week', TransactionExplorerDataDimensionType.DateTimeByDayOfWeek, false, false);
public static readonly DateTimeByDayOfMonth = new TransactionExplorerDataDimension('Transaction Day of Month', TransactionExplorerDataDimensionType.DateTimeByDayOfMonth, false, false);
public static readonly DateTimeByMonthOfYear = new TransactionExplorerDataDimension('Transaction Month of Year', TransactionExplorerDataDimensionType.DateTimeByMonthOfYear, false, false);
public static readonly DateTimeByQuarterOfYear = new TransactionExplorerDataDimension('Transaction Quarter of Year', TransactionExplorerDataDimensionType.DateTimeByQuarterOfYear, false, false);
public static readonly DateTimeByHourOfDay = new TransactionExplorerDataDimension('Transaction Hour of Day', TransactionExplorerDataDimensionType.DateTimeByHourOfDay, false, false);
public static readonly TimezoneOffset = new TransactionExplorerDataDimension('Transaction Timezone', TransactionExplorerDataDimensionType.TimezoneOffset, false, false);
public static readonly TransactionType = new TransactionExplorerDataDimension('Transaction Type', TransactionExplorerDataDimensionType.TransactionType, false, false);
public static readonly SourceAccount = new TransactionExplorerDataDimension('Source Account', TransactionExplorerDataDimensionType.SourceAccount, false, false);
public static readonly SourceAccountCategory = new TransactionExplorerDataDimension('Source Account Category', TransactionExplorerDataDimensionType.SourceAccountCategory, false, false);
public static readonly SourceAccountCurrency = new TransactionExplorerDataDimension('Source Account Currency', TransactionExplorerDataDimensionType.SourceAccountCurrency, false, false);
public static readonly DestinationAccount = new TransactionExplorerDataDimension('Destination Account', TransactionExplorerDataDimensionType.DestinationAccount, false, false);
public static readonly DestinationAccountCategory = new TransactionExplorerDataDimension('Destination Account Category', TransactionExplorerDataDimensionType.DestinationAccountCategory, false, false);
public static readonly DestinationAccountCurrency = new TransactionExplorerDataDimension('Destination Account Currency', TransactionExplorerDataDimensionType.DestinationAccountCurrency, false, false);
public static readonly PrimaryCategory = new TransactionExplorerDataDimension('Primary Category', TransactionExplorerDataDimensionType.PrimaryCategory, false, false);
public static readonly SecondaryCategory = new TransactionExplorerDataDimension('Secondary Category', TransactionExplorerDataDimensionType.SecondaryCategory, false, false);
public static readonly SourceAmount = new TransactionExplorerDataDimension('Amount', TransactionExplorerDataDimensionType.SourceAmount, false, false);
public static readonly DestinationAmount = new TransactionExplorerDataDimension('Transfer In Amount', TransactionExplorerDataDimensionType.DestinationAmount, false, false);
public static readonly SourceAmountRangeEqualFrequency = new TransactionExplorerDataDimension('Amount Range (Equal Frequency)', TransactionExplorerDataDimensionType.SourceAmountRangeEqualFrequency, true, false);
public static readonly SourceAmountRangeEqualWidth = new TransactionExplorerDataDimension('Amount Range (Equal Width)', TransactionExplorerDataDimensionType.SourceAmountRangeEqualWidth, true, false);
public static readonly SourceAmountRangeLogScale = new TransactionExplorerDataDimension('Amount Range (Log Scale)', TransactionExplorerDataDimensionType.SourceAmountRangeLogScale, true, false);
public static readonly SourceAmountRangeStandardDeviation = new TransactionExplorerDataDimension('Amount Range (Standard Deviation)', TransactionExplorerDataDimensionType.SourceAmountRangeStandardDeviation, true, false);
public static readonly SourceAmountRangeNaturalBreaks = new TransactionExplorerDataDimension('Amount Range (Natural Breaks)', TransactionExplorerDataDimensionType.SourceAmountRangeNaturalBreaks, true, false);
public static readonly DestinationAmountRangeEqualFrequency = new TransactionExplorerDataDimension('Transfer In Amount Range (Equal Frequency)', TransactionExplorerDataDimensionType.DestinationAmountRangeEqualFrequency, false, true);
public static readonly DestinationAmountRangeEqualWidth = new TransactionExplorerDataDimension('Transfer In Amount Range (Equal Width)', TransactionExplorerDataDimensionType.DestinationAmountRangeEqualWidth, false, true);
public static readonly DestinationAmountRangeLogScale = new TransactionExplorerDataDimension('Transfer In Amount Range (Log Scale)', TransactionExplorerDataDimensionType.DestinationAmountRangeLogScale, false, true);
public static readonly DestinationAmountRangeStandardDeviation = new TransactionExplorerDataDimension('Transfer In Amount Range (Standard Deviation)', TransactionExplorerDataDimensionType.DestinationAmountRangeStandardDeviation, false, true);
public static readonly DestinationAmountRangeNaturalBreaks = new TransactionExplorerDataDimension('Transfer In Amount Range (Natural Breaks)', TransactionExplorerDataDimensionType.DestinationAmountRangeNaturalBreaks, false, true);
public static readonly CategoryDimensionDefault = TransactionExplorerDataDimension.Query;
public static readonly SeriesDimensionDefault = TransactionExplorerDataDimension.None;
public readonly name: string;
public readonly value: TransactionExplorerDataDimensionType;
public readonly isSourceAmountRange: boolean;
public readonly isDestinationAmountRange: boolean;
private constructor(name: string, value: TransactionExplorerDataDimensionType) {
private constructor(name: string, value: TransactionExplorerDataDimensionType, isSourceAmountRange: boolean, isDestinationAmountRange: boolean) {
this.name = name;
this.value = value;
this.isSourceAmountRange = isSourceAmountRange;
this.isDestinationAmountRange = isDestinationAmountRange;
TransactionExplorerDataDimension.allInstances.push(this);
TransactionExplorerDataDimension.allInstancesByValue[value] = this;
@@ -279,47 +274,89 @@ export class TransactionExplorerDataDimension implements NameValue {
export enum TransactionExplorerValueMetricType {
TransactionCount = 'transactionCount',
ActiveTransactionDays = 'activeTransactionDays',
TransactionsPerActiveDay = 'transactionsPerActiveDay',
SourceAmountSum = 'sourceAmountSum',
SourceIncomeAmountSum = 'sourceIncomeAmountSum',
SourceExpenseAmountSum = 'sourceExpenseAmountSum',
SourceNetIncomeAmountSum = 'sourceNetIncomeAmountSum',
SrouceAmountExpenseIncomeRatio = 'sourceExpenseIncomeRatio',
SourceAmountSavingsRate = 'sourceAmountSavingsRate',
SourceAmountAverage = 'sourceAmountAverage',
SourceAmountMedian = 'sourceAmountMedian',
SourceAmount90thPercentile = 'source90thPercentileAmount',
SourceAmountMinimum = 'sourceAmountMinimum',
SourceAmountMaximum = 'sourceAmountMaximum',
SourceAmountQ1Amount = 'sourceQ1Amount',
SourceAmountQ3Amount = 'sourceQ3Amount',
SourceAmount10thPercentile = 'source10thPercentileAmount',
SourceAmount90thPercentile = 'source90thPercentileAmount',
SourceAmount95thPercentile = 'source95thPercentileAmount',
SourceAmount99thPercentile = 'source99thPercentileAmount',
SourceAmountRange = 'sourceAmountRange',
SourceAmountInterquartileRange = 'sourceAmountInterquartileRange',
SourceAmountMeanAbsoluteDeviation = 'sourceAmountMeanAbsoluteDeviation',
SourceAmountMedianAbsoluteDeviation = 'sourceAmountMedianAbsoluteDeviation',
SourceMaximumAmountShare = 'sourceMaximumAmountShare',
SourceTop5AmountSum = 'sourceTop5AmountSum',
SourceTop5AmountShare = 'sourceTop5AmountShare',
TransactionsForEightyPercentOfSourceAmount = 'transactionsForEightyPercentOfSourceAmount',
SourceAmountVariance = 'sourceAmountVariance',
SourceAmountStandardDeviation = 'sourceAmountStandardDeviation',
SourceAmountCoefficientOfVariation = 'sourceAmountCoefficientOfVariation'
SourceAmountCoefficientOfVariation = 'sourceAmountCoefficientOfVariation',
SourceAmountSkewness = 'sourceAmountSkewness',
SourceAmountKurtosis = 'sourceAmountKurtosis'
}
export class TransactionExplorerValueMetric implements NameValue {
private static readonly allInstances: TransactionExplorerValueMetric[] = [];
private static readonly allInstancesByValue: Record<string, TransactionExplorerValueMetric> = {};
public static readonly TransactionCount = new TransactionExplorerValueMetric('Transaction Count', TransactionExplorerValueMetricType.TransactionCount, false, true);
public static readonly SourceAmountSum = new TransactionExplorerValueMetric('Total Amount', TransactionExplorerValueMetricType.SourceAmountSum, true, true);
public static readonly SourceAmountAverage = new TransactionExplorerValueMetric('Average Amount', TransactionExplorerValueMetricType.SourceAmountAverage, true, true);
public static readonly SourceAmountMedian = new TransactionExplorerValueMetric('Median Amount', TransactionExplorerValueMetricType.SourceAmountMedian, true, true);
public static readonly SourceAmount90thPercentile = new TransactionExplorerValueMetric('90th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount90thPercentile, true, true);
public static readonly SourceAmountMinimum = new TransactionExplorerValueMetric('Minimum Amount', TransactionExplorerValueMetricType.SourceAmountMinimum, true, true);
public static readonly SourceAmountMaximum = new TransactionExplorerValueMetric('Maximum Amount', TransactionExplorerValueMetricType.SourceAmountMaximum, true, true);
public static readonly SourceAmountRange = new TransactionExplorerValueMetric('Range (Max - Min)', TransactionExplorerValueMetricType.SourceAmountRange, true, true);
public static readonly SourceAmountInterquartileRange = new TransactionExplorerValueMetric('Interquartile Range (Q3 - Q1)', TransactionExplorerValueMetricType.SourceAmountInterquartileRange, true, true);
public static readonly SourceAmountVariance = new TransactionExplorerValueMetric('Variance', TransactionExplorerValueMetricType.SourceAmountVariance, false, false);
public static readonly SourceAmountStandardDeviation = new TransactionExplorerValueMetric('Standard Deviation', TransactionExplorerValueMetricType.SourceAmountStandardDeviation, false, false);
public static readonly SourceAmountCoefficientOfVariation = new TransactionExplorerValueMetric('Coefficient of Variation', TransactionExplorerValueMetricType.SourceAmountCoefficientOfVariation, false, false);
public static readonly TransactionCount = new TransactionExplorerValueMetric('Transaction Count', TransactionExplorerValueMetricType.TransactionCount, false, false, true);
public static readonly ActiveTransactionDays = new TransactionExplorerValueMetric('Active Transaction Days', TransactionExplorerValueMetricType.ActiveTransactionDays, false, false, true);
public static readonly TransactionsPerDay = new TransactionExplorerValueMetric('Transactions per Active Day', TransactionExplorerValueMetricType.TransactionsPerActiveDay, false, false, true);
public static readonly SourceAmountSum = new TransactionExplorerValueMetric('Total Amount', TransactionExplorerValueMetricType.SourceAmountSum, true, false, true);
public static readonly SourceIncomeAmountSum = new TransactionExplorerValueMetric('Total Income', TransactionExplorerValueMetricType.SourceIncomeAmountSum, true, false, true);
public static readonly SourceExpenseAmountSum = new TransactionExplorerValueMetric('Total Expense', TransactionExplorerValueMetricType.SourceExpenseAmountSum, true, false, true);
public static readonly SourceNetIncomeAmountSum = new TransactionExplorerValueMetric('Net Income', TransactionExplorerValueMetricType.SourceNetIncomeAmountSum, true, false, true);
public static readonly SrouceAmountExpenseIncomeRatio = new TransactionExplorerValueMetric('Expense / Income Ratio', TransactionExplorerValueMetricType.SrouceAmountExpenseIncomeRatio, false, true, false);
public static readonly SourceAmountSavingsRate = new TransactionExplorerValueMetric('Savings Rate', TransactionExplorerValueMetricType.SourceAmountSavingsRate, false, true, false);
public static readonly SourceAmountAverage = new TransactionExplorerValueMetric('Average Amount', TransactionExplorerValueMetricType.SourceAmountAverage, true, false, true);
public static readonly SourceAmountMedian = new TransactionExplorerValueMetric('Median Amount', TransactionExplorerValueMetricType.SourceAmountMedian, true, false, true);
public static readonly SourceAmountMinimum = new TransactionExplorerValueMetric('Minimum Amount', TransactionExplorerValueMetricType.SourceAmountMinimum, true, false, true);
public static readonly SourceAmountMaximum = new TransactionExplorerValueMetric('Maximum Amount', TransactionExplorerValueMetricType.SourceAmountMaximum, true, false, true);
public static readonly SourceAmountQ1Amount = new TransactionExplorerValueMetric('Q1 Amount (First Quartile)', TransactionExplorerValueMetricType.SourceAmountQ1Amount, true, false, true);
public static readonly SourceAmountQ3Amount = new TransactionExplorerValueMetric('Q3 Amount (Third Quartile)', TransactionExplorerValueMetricType.SourceAmountQ3Amount, true, false, true);
public static readonly SourceAmount10thPercentile = new TransactionExplorerValueMetric('10th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount10thPercentile, true, false, true);
public static readonly SourceAmount90thPercentile = new TransactionExplorerValueMetric('90th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount90thPercentile, true, false, true);
public static readonly SourceAmount95thPercentile = new TransactionExplorerValueMetric('95th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount95thPercentile, true, false, true);
public static readonly SourceAmount99thPercentile = new TransactionExplorerValueMetric('99th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount99thPercentile, true, false, true);
public static readonly SourceAmountRange = new TransactionExplorerValueMetric('Range (Max - Min)', TransactionExplorerValueMetricType.SourceAmountRange, true, false, true);
public static readonly SourceAmountInterquartileRange = new TransactionExplorerValueMetric('Interquartile Range (Q3 - Q1)', TransactionExplorerValueMetricType.SourceAmountInterquartileRange, true, false, true);
public static readonly SourceAmountMeanAbsoluteDeviation = new TransactionExplorerValueMetric('Mean Absolute Deviation', TransactionExplorerValueMetricType.SourceAmountMeanAbsoluteDeviation, true, false, false);
public static readonly SourceAmountMedianAbsoluteDeviation = new TransactionExplorerValueMetric('Median Absolute Deviation', TransactionExplorerValueMetricType.SourceAmountMedianAbsoluteDeviation, true, false, false);
public static readonly SourceMaximumAmountShare = new TransactionExplorerValueMetric('Maximum Amount Share', TransactionExplorerValueMetricType.SourceMaximumAmountShare, false, true, false);
public static readonly SourceTop5AmountSum = new TransactionExplorerValueMetric('Top 5 Amount Sum', TransactionExplorerValueMetricType.SourceTop5AmountSum, true, false, true);
public static readonly SourceTop5AmountShare = new TransactionExplorerValueMetric('Top 5 Amount Share', TransactionExplorerValueMetricType.SourceTop5AmountShare, false, true, false);
public static readonly TransactionsForEightyPercentOfSourceAmount = new TransactionExplorerValueMetric('Transactions for 80% of Amount', TransactionExplorerValueMetricType.TransactionsForEightyPercentOfSourceAmount, false, true, false);
public static readonly SourceAmountVariance = new TransactionExplorerValueMetric('Variance', TransactionExplorerValueMetricType.SourceAmountVariance, false, false, false);
public static readonly SourceAmountStandardDeviation = new TransactionExplorerValueMetric('Standard Deviation', TransactionExplorerValueMetricType.SourceAmountStandardDeviation, false, false, false);
public static readonly SourceAmountCoefficientOfVariation = new TransactionExplorerValueMetric('Coefficient of Variation', TransactionExplorerValueMetricType.SourceAmountCoefficientOfVariation, false, false, false);
public static readonly SourceAmountSkewness = new TransactionExplorerValueMetric('Skewness', TransactionExplorerValueMetricType.SourceAmountSkewness, false, false, false);
public static readonly SourceAmountKurtosis = new TransactionExplorerValueMetric('Kurtosis', TransactionExplorerValueMetricType.SourceAmountKurtosis, false, false, false);
public static readonly Default = TransactionExplorerValueMetric.SourceAmountSum;
public readonly name: string;
public readonly value: TransactionExplorerValueMetricType;
public readonly isAmount: boolean;
public readonly isPercent: boolean;
public readonly supportSum: boolean;
private constructor(name: string, value: TransactionExplorerValueMetricType, isAmount: boolean, supportSum: boolean) {
private constructor(name: string, value: TransactionExplorerValueMetricType, isAmount: boolean, isPercent: boolean, supportSum: boolean) {
this.name = name;
this.value = value;
this.isAmount = isAmount;
this.isPercent = isPercent;
this.supportSum = supportSum;
TransactionExplorerValueMetric.allInstances.push(this);
@@ -335,4 +372,66 @@ export class TransactionExplorerValueMetric implements NameValue {
}
}
export enum TransactionExplorerChartTypeValue {
Pie = 'pie',
ColumnStacked = 'columnStacked',
Column100PercentStacked = 'column100%Stacked',
ColumnGrouped = 'columnGrouped',
LineGrouped = 'lineGrouped',
AreaStacked = 'areaStacked',
Area100PercentStacked = 'area100%Stacked',
BubbleGrouped = 'bubbleGrouped',
Radar = 'radar',
Treemap = 'treemap',
Sunburst = 'sunburst',
Heatmap = 'heatmap',
CalendarHeatmap = 'calendarHeatmap'
}
export class TransactionExplorerChartType implements NameValue {
private static readonly allInstances: TransactionExplorerChartType[] = [];
private static readonly allInstancesByValue: Record<string, TransactionExplorerChartType> = {};
public static readonly Pie = new TransactionExplorerChartType('Pie Chart', TransactionExplorerChartTypeValue.Pie, undefined, false, undefined);
public static readonly Radar = new TransactionExplorerChartType('Radar Chart', TransactionExplorerChartTypeValue.Radar, undefined, false, undefined);
public static readonly ColumnStacked = new TransactionExplorerChartType('Column Chart (Stacked)', TransactionExplorerChartTypeValue.ColumnStacked, undefined, true, undefined);
public static readonly Column100PercentStacked = new TransactionExplorerChartType('Column Chart (100% Stacked)', TransactionExplorerChartTypeValue.Column100PercentStacked, undefined, true, undefined);
public static readonly ColumnGrouped = new TransactionExplorerChartType('Column Chart (Grouped)', TransactionExplorerChartTypeValue.ColumnGrouped, undefined, true, undefined);
public static readonly LineGrouped = new TransactionExplorerChartType('Line Chart (Grouped)', TransactionExplorerChartTypeValue.LineGrouped, undefined, true, undefined);
public static readonly AreaStacked = new TransactionExplorerChartType('Area Chart (Stacked)', TransactionExplorerChartTypeValue.AreaStacked, undefined, true, undefined);
public static readonly Area100PercentStacked = new TransactionExplorerChartType('Area Chart (100% Stacked)', TransactionExplorerChartTypeValue.Area100PercentStacked, undefined, true, undefined);
public static readonly BubbleGrouped = new TransactionExplorerChartType('Bubble Chart (Grouped)', TransactionExplorerChartTypeValue.BubbleGrouped, undefined, true, undefined);
public static readonly Treemap = new TransactionExplorerChartType('Treemap Chart', TransactionExplorerChartTypeValue.Treemap, undefined, true, undefined);
public static readonly Sunburst = new TransactionExplorerChartType('Sunburst Chart', TransactionExplorerChartTypeValue.Sunburst, undefined, true, undefined);
public static readonly Heatmap = new TransactionExplorerChartType('Heatmap Chart', TransactionExplorerChartTypeValue.Heatmap, undefined, true, undefined);
public static readonly CalendarHeatmap = new TransactionExplorerChartType('Calendar Heatmap Chart', TransactionExplorerChartTypeValue.CalendarHeatmap, TransactionExplorerDataDimensionType.DateTimeByYearMonthDay, false, ChartSortingType.DisplayOrder.type);
public static readonly Default = TransactionExplorerChartType.Pie;
public readonly name: string;
public readonly value: TransactionExplorerChartTypeValue;
public readonly fixedCategoryDimension: TransactionExplorerDataDimensionType | undefined;
public readonly seriesDimensionRequired: boolean;
public readonly fixedSortingType: number | undefined;
private constructor(name: string, value: TransactionExplorerChartTypeValue, fixedCategoryDimension: TransactionExplorerDataDimensionType | undefined, seriesDimensionRequired: boolean, fixedSortingType: number | undefined) {
this.name = name;
this.value = value;
this.fixedCategoryDimension = fixedCategoryDimension;
this.seriesDimensionRequired = seriesDimensionRequired;
this.fixedSortingType = fixedSortingType;
TransactionExplorerChartType.allInstances.push(this);
TransactionExplorerChartType.allInstancesByValue[value] = this;
}
public static values(): TransactionExplorerChartType[] {
return TransactionExplorerChartType.allInstances;
}
public static valueOf(value: string): TransactionExplorerChartType | undefined {
return TransactionExplorerChartType.allInstancesByValue[value];
}
}
export const DEFAULT_TRANSACTION_EXPLORER_DATE_RANGE: DateRange = DateRange.ThisMonth;
+9 -1
View File
@@ -12,7 +12,9 @@ import {
ChartSortingType,
DEFAULT_CATEGORICAL_CHART_DATA_RANGE,
DEFAULT_TREND_CHART_DATA_RANGE,
DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE
DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE,
DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_DESKTOP,
DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_MOBILE,
} from './statistics.ts';
import { DEFAULT_TRANSACTION_EXPLORER_DATE_RANGE } from './explorer.ts';
import { DEFAULT_CURRENCY_CODE } from '@/consts/currency.ts';
@@ -66,6 +68,8 @@ export interface ApplicationSettings extends BaseApplicationSetting {
totalAmountExcludeAccountIds: Record<string, boolean>;
accountCategoryOrders: string;
hideCategoriesWithoutAccounts: boolean;
reconciliationStatementButtonDefaultDateRangeTypeInDesktop: number;
reconciliationStatementPageDefaultDateRangeTypeInMobile: number;
// Exchange Rates Data Page
currencySortByInExchangeRatesPage: number;
// Browser Cache Management
@@ -144,6 +148,8 @@ export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserAp
'totalAmountExcludeAccountIds': UserApplicationCloudSettingType.StringBooleanMap,
'accountCategoryOrders': UserApplicationCloudSettingType.String,
'hideCategoriesWithoutAccounts': UserApplicationCloudSettingType.Boolean,
'reconciliationStatementButtonDefaultDateRangeTypeInDesktop': UserApplicationCloudSettingType.Number,
'reconciliationStatementPageDefaultDateRangeTypeInMobile': UserApplicationCloudSettingType.Number,
// Exchange Rates Data Page
'currencySortByInExchangeRatesPage': UserApplicationCloudSettingType.Number,
// Browser Cache Management
@@ -204,6 +210,8 @@ export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = {
totalAmountExcludeAccountIds: {},
accountCategoryOrders: '',
hideCategoriesWithoutAccounts: false,
reconciliationStatementButtonDefaultDateRangeTypeInDesktop: DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_DESKTOP.type,
reconciliationStatementPageDefaultDateRangeTypeInMobile: DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_MOBILE.type,
// Exchange Rates Data Page
currencySortByInExchangeRatesPage: CurrencySortingType.Default.type,
// Browser Cache Management
+5
View File
@@ -236,6 +236,8 @@ export class ChartDateAggregationType {
public static readonly Year = new ChartDateAggregationType(2, 'Yearly', 'Aggregate by Year', StatisticsAnalysisType.TrendAnalysis, StatisticsAnalysisType.AssetTrends);
public static readonly FiscalYear = new ChartDateAggregationType(3, 'FiscalYearly', 'Aggregate by Fiscal Year', StatisticsAnalysisType.TrendAnalysis, StatisticsAnalysisType.AssetTrends);
public static readonly BillingCycle = new ChartDateAggregationType(11, 'BillingCycle', 'Aggregate by Billing Cycle');
public static readonly Default = ChartDateAggregationType.Month;
public readonly type: number;
@@ -295,3 +297,6 @@ export enum ExportMermaidChartType {
export const DEFAULT_CATEGORICAL_CHART_DATA_RANGE: DateRange = DateRange.ThisMonth;
export const DEFAULT_TREND_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
export const DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
export const DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_DESKTOP: DateRange = DateRange.Custom;
export const DEFAULT_RECONCILIATION_STATEMENT_DATE_RANGE_IN_MOBILE: DateRange = DateRange.ThisMonth;
+2
View File
@@ -32,8 +32,10 @@ export class ScheduledTemplateFrequencyType implements TypeAndName {
private static readonly allInstancesByType: Record<number, ScheduledTemplateFrequencyType> = {};
public static readonly Disabled = new ScheduledTemplateFrequencyType(0, 'Disabled');
public static readonly Daily = new ScheduledTemplateFrequencyType(3, 'Daily');
public static readonly Weekly = new ScheduledTemplateFrequencyType(1, 'Weekly');
public static readonly Monthly = new ScheduledTemplateFrequencyType(2, 'Monthly');
public static readonly Yearly = new ScheduledTemplateFrequencyType(4, 'Yearly');
public readonly type: number;
public readonly name: string;
+28 -9
View File
@@ -14,27 +14,46 @@ export enum TransactionRelatedAccountType {
export class TransactionEditScopeType implements TypeAndName {
private static readonly allInstances: TransactionEditScopeType[] = [];
private static readonly allInstancesWithoutReconciledTime: TransactionEditScopeType[] = [];
private static readonly allInstancesByType: Record<number, TransactionEditScopeType> = {};
public static readonly None = new TransactionEditScopeType(0, 'None');
public static readonly All = new TransactionEditScopeType(1, 'All');
public static readonly TodayOrLater = new TransactionEditScopeType(2, 'Today or later');
public static readonly Recent24HoursOrLater = new TransactionEditScopeType(3, 'Recent 24 hours or later');
public static readonly ThisWeekOrLater = new TransactionEditScopeType(4, 'This week or later');
public static readonly ThisMonthOrLater = new TransactionEditScopeType(5, 'This month or later');
public static readonly ThisYearOrLater = new TransactionEditScopeType(6, 'This year or later');
public static readonly None = new TransactionEditScopeType(0, 'None', false);
public static readonly All = new TransactionEditScopeType(1, 'All', false);
public static readonly TodayOrLater = new TransactionEditScopeType(2, 'Today or later', false);
public static readonly Recent24HoursOrLater = new TransactionEditScopeType(3, 'Recent 24 hours or later', false);
public static readonly ThisWeekOrLater = new TransactionEditScopeType(4, 'This week or later', false);
public static readonly ThisMonthOrLater = new TransactionEditScopeType(5, 'This month or later', false);
public static readonly ThisYearOrLater = new TransactionEditScopeType(6, 'This year or later', false);
public static readonly LastReconciledTimeOrlater = new TransactionEditScopeType(7, 'Last reconciled time or later', true);
public readonly type: number;
public readonly name: string;
public readonly needLastReconciledTime: boolean;
private constructor(type: number, name: string) {
private constructor(type: number, name: string, needLastReconciledTime: boolean) {
this.type = type;
this.name = name;
this.needLastReconciledTime = needLastReconciledTime;
TransactionEditScopeType.allInstances.push(this);
if (!needLastReconciledTime) {
TransactionEditScopeType.allInstancesWithoutReconciledTime.push(this);
}
public static values(): TransactionEditScopeType[] {
TransactionEditScopeType.allInstancesByType[type] = this;
}
public static values(useLastReconciledTime: boolean): TransactionEditScopeType[] {
if (useLastReconciledTime) {
return TransactionEditScopeType.allInstances;
} else {
return TransactionEditScopeType.allInstancesWithoutReconciledTime;
}
}
public static valueOf(type: number): TransactionEditScopeType | undefined {
return TransactionEditScopeType.allInstancesByType[type];
}
}
+27 -2
View File
@@ -52,11 +52,25 @@ import 'vuetify/styles';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart, BarChart, PieChart, ScatterChart, BoxplotChart, CandlestickChart, RadarChart, SankeyChart } from 'echarts/charts';
import {
LineChart,
BarChart,
PieChart,
ScatterChart,
BoxplotChart,
CandlestickChart,
RadarChart,
TreemapChart,
SunburstChart,
HeatmapChart,
SankeyChart
} from 'echarts/charts';
import {
GridComponent,
CalendarComponent,
TooltipComponent,
LegendComponent,
VisualMapComponent
} from 'echarts/components';
import VChart from 'vue-echarts';
@@ -103,6 +117,9 @@ import PieChartComponent from '@/components/desktop/PieChart.vue';
import RadarChartComponent from '@/components/desktop/RadarChart.vue';
import AxisChart from '@/components/desktop/AxisChart.vue';
import TrendsChart from '@/components/desktop/TrendsChart.vue';
import HierarchyChart from '@/components/desktop/HierarchyChart.vue';
import HeatMapChart from '@/components/desktop/HeatMapChart.vue';
import CalendarHeatMapChart from '@/components/desktop/CalendarHeatMapChart.vue';
import RenameDialog from '@/components/desktop/RenameDialog.vue';
import DateRangeSelectionDialog from '@/components/desktop/DateRangeSelectionDialog.vue';
import MonthSelectionDialog from '@/components/desktop/MonthSelectionDialog.vue';
@@ -506,10 +523,15 @@ echarts.use([
BoxplotChart,
CandlestickChart,
RadarChart,
TreemapChart,
SunburstChart,
HeatmapChart,
SankeyChart,
GridComponent,
CalendarComponent,
TooltipComponent,
LegendComponent
LegendComponent,
VisualMapComponent
]);
app.use(pinia);
@@ -551,6 +573,9 @@ app.component('PieChart', PieChartComponent);
app.component('RadarChart', RadarChartComponent);
app.component('AxisChart', AxisChart);
app.component('TrendsChart', TrendsChart);
app.component('HierarchyChart', HierarchyChart);
app.component('HeatMapChart', HeatMapChart);
app.component('CalendarHeatMapChart', CalendarHeatMapChart);
app.component('RenameDialog', RenameDialog);
app.component('DateRangeSelectionDialog', DateRangeSelectionDialog);
app.component('MonthSelectionDialog', MonthSelectionDialog);
+3
View File
@@ -1,3 +1,6 @@
declare module 'vuetify/styles';
declare module 'framework7-icons';
declare const __EZBOOKKEEPING_IS_PRODUCTION__: boolean;
declare const __EZBOOKKEEPING_VERSION__: string;
declare const __EZBOOKKEEPING_BUILD_UNIX_TIME__: string;
+225
View File
@@ -0,0 +1,225 @@
import fs from 'fs';
import path from 'path';
import { describe, expect, it, beforeAll } from 'vitest';
import moment from 'moment-timezone';
import type { TextualYearMonth } from '@/core/datetime.ts';
import { FiscalYearStart, FiscalYearUnixTime } from '@/core/fiscalyear.ts';
import {
getFiscalYearFromUnixTime,
getFiscalYearStartUnixTime,
getFiscalYearEndUnixTime,
getFiscalYearTimeRangeFromUnixTime,
getAllFiscalYearsStartAndEndUnixTimes,
getFiscalYearTimeRangeFromYear
} from '@/lib/datetime.ts';
// Set test environment timezone to UTC, since the test data constants are in UTC
beforeAll(() => {
moment.tz.setDefault('UTC');
});
function importTestData(datasetName: string): unknown[] {
const data = JSON.parse(
fs.readFileSync(path.join(__dirname, 'fiscal_year.data.json'), 'utf8')
);
if (!data || typeof data[datasetName] === 'undefined') {
throw new Error(`${datasetName} is undefined or missing in the data object.`);
}
return data[datasetName];
}
function formatUnixTimeISO(unixTime: number): string {
return moment.unix(unixTime).format('YYYY-MM-DDTHH:mm:ssZ');
}
function withISO(data: FiscalYearUnixTime) {
return {
...data,
minUnixTimeISO: formatUnixTimeISO(data.minUnixTime),
maxUnixTimeISO: formatUnixTimeISO(data.maxUnixTime),
};
}
type FiscalYearStartConfig = {
id: string;
monthDateString: string;
value: number;
};
const FISCAL_YEAR_START_PRESETS: Record<string, FiscalYearStartConfig> = {
'January 1': { id: 'January 1', monthDateString: '01-01', value: 0x0101 },
'April 1': { id: 'April 1', monthDateString: '04-01', value: 0x0401 },
'October 1': { id: 'October 1', monthDateString: '10-01', value: 0x0A01 },
};
describe('validateFiscalYearStart', () => {
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value, monthDateString }) => {
it(`should return a fiscal year start object for valid value 0x${value.toString(16)} (${id})`, () => {
expect(FiscalYearStart.valueOf(value)).toBeDefined();
});
it(`should return the correct month-date string for valid value 0x${value.toString(16)} (${id})`, () => {
expect(FiscalYearStart.valueOf(value)?.toMonthDashDayString()).toStrictEqual(monthDateString);
});
});
});
const INVALID_FISCAL_YEAR_VALUES = [
0x0000, // Invalid: L0/0
0x0D01, // Invalid: Month 13
0x0100, // Invalid: Day 0
0x0120, // Invalid: January 32
0x021D, // Invalid: February 29 (not permitted)
0x021E, // Invalid: February 30
0x041F, // Invalid: April 31
0x061F, // Invalid: June 31
0x091F, // Invalid: September 31
0x0B20, // Invalid: November 32
0xFFFF, // Invalid: Largest uint16
];
describe('validateFiscalYearStartInvalidValues', () => {
INVALID_FISCAL_YEAR_VALUES.forEach((value) => {
it(`should return undefined for invalid fiscal year start value 0x${value.toString(16)}`, () => {
expect(FiscalYearStart.valueOf(value)).not.toBeDefined();
});
});
});
describe('validateFiscalYearStartLeapDay', () => {
it('should return undefined for February 29 value (0x021D)', () => {
expect(FiscalYearStart.valueOf(0x021D)).not.toBeDefined();
});
it('should return undefined when parsing month-day string "02-29"', () => {
expect(FiscalYearStart.parse('02-29')).not.toBeDefined();
});
});
type FiscalYearFromUnixTimeCase = {
date: string;
unixTime: number;
expected: { [fiscalYearStartId: string]: number };
};
const TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME =
importTestData('test_cases_getFiscalYearFromUnixTime') as FiscalYearFromUnixTimeCase[];
describe('getFiscalYearFromUnixTime', () => {
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value }) => {
TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME.forEach((testCase) => {
it(`should return correct fiscal year for FY_START ${id}, date ${moment(testCase.date).format('MMMM D, YYYY')}`, () => {
expect(getFiscalYearFromUnixTime(moment(testCase.date).unix(), value)).toBe(testCase.expected[id]);
});
});
});
});
type FiscalYearStartUnixTimeCase = {
date: string;
expected: {
[fiscalYearStart: string]: { unixTime: number; unixTimeISO: string };
};
};
const TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME =
importTestData('test_cases_getFiscalYearStartUnixTime') as FiscalYearStartUnixTimeCase[];
describe('getFiscalYearStartUnixTime', () => {
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value }) => {
TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME.forEach((testCase) => {
it(`should return correct start unix time for FY_START ${id}, date ${moment(testCase.date).format('MMMM D, YYYY')}`, () => {
const startUnixTime = getFiscalYearStartUnixTime(moment(testCase.date).unix(), value);
const expected = testCase.expected[id];
expect({ unixTime: startUnixTime, ISO: formatUnixTimeISO(startUnixTime) })
.toStrictEqual({ unixTime: expected!.unixTime, ISO: expected!.unixTimeISO });
});
});
});
});
type FiscalYearEndUnixTimeCase = {
date: string;
expected: {
[fiscalYearStart: string]: { unixTime: number; unixTimeISO: string };
};
};
const TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME =
importTestData('test_cases_getFiscalYearEndUnixTime') as FiscalYearEndUnixTimeCase[];
describe('getFiscalYearEndUnixTime', () => {
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value }) => {
TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME.forEach((testCase) => {
it(`should return correct end unix time for FY_START ${id}, date ${moment(testCase.date).format('MMMM D, YYYY')}`, () => {
const endUnixTime = getFiscalYearEndUnixTime(moment(testCase.date).unix(), value);
const expected = testCase.expected[id];
expect({ unixTime: endUnixTime, ISO: formatUnixTimeISO(endUnixTime) })
.toStrictEqual({ unixTime: expected!.unixTime, ISO: expected!.unixTimeISO });
});
});
});
});
type FiscalYearTimeRangeFromUnixTimeCase = {
date: string;
expected: { [fiscalYearStart: string]: FiscalYearUnixTime[] };
};
const TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE =
importTestData('test_cases_getFiscalYearTimeRangeFromUnixTime') as FiscalYearTimeRangeFromUnixTimeCase[];
describe('getFiscalYearTimeRangeFromUnixTime', () => {
Object.values(FISCAL_YEAR_START_PRESETS).forEach(({ id, value }) => {
TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE.forEach((testCase) => {
it(`should return correct fiscal year unix time range for FY_START ${id}, date ${moment(testCase.date).format('MMMM D, YYYY')}`, () => {
expect(getFiscalYearTimeRangeFromUnixTime(moment(testCase.date).unix(), value))
.toStrictEqual(testCase.expected[id]);
});
});
});
});
type AllFiscalYearsStartAndEndUnixTimesCase = {
startYearMonth: TextualYearMonth;
endYearMonth: TextualYearMonth;
fiscalYearStart: string;
fiscalYearStartId: string;
expected: FiscalYearUnixTime[];
};
const TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES =
importTestData('test_cases_getAllFiscalYearsStartAndEndUnixTimes') as AllFiscalYearsStartAndEndUnixTimesCase[];
describe('getAllFiscalYearsStartAndEndUnixTimes', () => {
TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES.forEach((testCase) => {
it(`should return correct fiscal year start and end unix times for FY_START ${testCase.fiscalYearStartId}, range ${testCase.startYearMonth} to ${testCase.endYearMonth}`, () => {
const fiscalYearStart = FiscalYearStart.parse(testCase.fiscalYearStart);
expect(fiscalYearStart).toBeDefined();
expect(getAllFiscalYearsStartAndEndUnixTimes(testCase.startYearMonth, testCase.endYearMonth, fiscalYearStart?.value || 0).map(withISO))
.toStrictEqual(testCase.expected.map(withISO));
});
});
});
type FiscalYearTimeRangeFromYearCase = {
year: number;
fiscalYearStart: string;
expected: FiscalYearUnixTime;
};
const TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR =
importTestData('test_cases_getFiscalYearTimeRangeFromYear') as FiscalYearTimeRangeFromYearCase[];
describe('getFiscalYearTimeRangeFromYear', () => {
TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR.forEach((testCase) => {
it(`should return correct fiscal year unix time range for year ${testCase.year} and FY_START ${testCase.fiscalYearStart}`, () => {
const fiscalYearStart = FiscalYearStart.parse(testCase.fiscalYearStart);
expect(fiscalYearStart).toBeDefined();
expect(getFiscalYearTimeRangeFromYear(testCase.year, fiscalYearStart?.value || 0))
.toStrictEqual(testCase.expected);
});
});
});
-289
View File
@@ -1,289 +0,0 @@
// Unit tests for fiscal year functions
import fs from 'fs';
import path from 'path';
import { describe, expect, test, beforeAll } from '@jest/globals';
import moment from 'moment-timezone';
// Import all the fiscal year functions from the lib
import type { TextualYearMonth } from '@/core/datetime.ts';
import { FiscalYearStart, FiscalYearUnixTime } from '@/core/fiscalyear.ts';
import {
getFiscalYearFromUnixTime,
getFiscalYearStartUnixTime,
getFiscalYearEndUnixTime,
getFiscalYearTimeRangeFromUnixTime,
getAllFiscalYearsStartAndEndUnixTimes,
getFiscalYearTimeRangeFromYear
} from '@/lib/datetime.ts';
// Set test environment timezone to UTC, since the test data constants are in UTC
beforeAll(() => {
moment.tz.setDefault('UTC');
});
// UTILITIES
function importTestData(datasetName: string): unknown[] {
const data = JSON.parse(
fs.readFileSync(path.join(__dirname, 'fiscal_year.data.json'), 'utf8')
);
if (!data || typeof data[datasetName] === 'undefined') {
throw new Error(`${datasetName} is undefined or missing in the data object.`);
}
return data[datasetName];
}
function formatUnixTimeISO(unixTime: number): string {
return moment.unix(unixTime).format('YYYY-MM-DDTHH:mm:ssZ');
}
function getTestTitleFormatDate(testFiscalYearStartId: string, testCaseDateString: string): string {
return `FY_START: ${testFiscalYearStartId.padStart(10, ' ')}; DATE: ${moment(testCaseDateString).format('MMMM D, YYYY')}`;
}
function getTestTitleFormatString(testFiscalYearStartId: string, testCaseString: string): string {
return `FY_START: ${testFiscalYearStartId.padStart(10, ' ')}; ${testCaseString}`;
}
// FISCAL YEAR START CONFIGURATION
type FiscalYearStartConfig = {
id: string;
monthDateString: string;
value: number;
};
const TEST_FISCAL_YEAR_START_PRESETS: Record<string, FiscalYearStartConfig> = {
'January 1': {
id: 'January 1',
monthDateString: '01-01',
value: 0x0101,
},
'April 1': {
id: 'April 1',
monthDateString: '04-01',
value: 0x0401,
},
'October 1': {
id: 'October 1',
monthDateString: '10-01',
value: 0x0A01,
},
};
// VALIDATE FISCAL YEAR START PRESETS
describe('validateFiscalYearStart', () => {
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
test(`should return fiscal year start object if fiscal year start value (uint16) is valid: id: ${testFiscalYearStart.id}; value: 0x${testFiscalYearStart.value.toString(16)}`, () => {
expect(FiscalYearStart.valueOf(testFiscalYearStart.value)).toBeDefined();
});
test(`returns same month-date string for valid fiscal year start value: id: ${testFiscalYearStart.id}; value: 0x${testFiscalYearStart.value.toString(16)}`, () => {
const fiscalYearStart = FiscalYearStart.valueOf(testFiscalYearStart.value);
expect(fiscalYearStart?.toMonthDashDayString()).toStrictEqual(testFiscalYearStart.monthDateString);
});
});
});
// VALIDATE INVALID FISCAL YEAR START VALUES
const TestCase_invalidFiscalYearValues = [
0x0000, // Invalid: L0/0
0x0D01, // Invalid: Month 13
0x0100, // Invalid: Day 0
0x0120, // Invalid: January 32
0x021D, // Invalid: February 29 (not permitted)
0x021E, // Invalid: February 30
0x041F, // Invalid: April 31
0x061F, // Invalid: June 31
0x091F, // Invalid: September 31
0x0B20, // Invalid: November 32
0xFFFF, // Invalid: Largest uint16
]
describe('validateFiscalYearStartInvalidValues', () => {
TestCase_invalidFiscalYearValues.forEach((testCase) => {
test(`should return undefined if fiscal year start value (uint16) is invalid: value: 0x${testCase.toString(16)}`, () => {
expect(FiscalYearStart.valueOf(testCase)).not.toBeDefined();
});
});
});
// VALIDATE LEAP DAY FEBRUARY 29 IS NOT VALID
describe('validateFiscalYearStartLeapDay', () => {
test(`should return undefined if fiscal year start value (uint16) for February 29 is invalid: value: 0x0229}`, () => {
expect(FiscalYearStart.valueOf(0x021D)).not.toBeDefined();
});
test(`should return undefined if fiscal year month-day string "02-29" is used to create fiscal year start object`, () => {
expect(FiscalYearStart.parse('02-29')).not.toBeDefined();
});
});
// FISCAL YEAR FROM UNIX TIME
type TestCase_getFiscalYearFromUnixTime = {
date: string;
unixTime: number;
expected: {
[fiscalYearStartId: string]: number;
};
};
const TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME: TestCase_getFiscalYearFromUnixTime[] =
importTestData('test_cases_getFiscalYearFromUnixTime') as TestCase_getFiscalYearFromUnixTime[];
describe('getFiscalYearFromUnixTime', () => {
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME.forEach((testCase) => {
test(`returns correct fiscal year for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => {
const testCaseUnixTime = moment(testCase.date).unix();
const fiscalYear = getFiscalYearFromUnixTime(testCaseUnixTime, testFiscalYearStart.value);
const expected = testCase.expected[testFiscalYearStart.id];
expect(fiscalYear).toBe(expected);
});
});
});
});
// FISCAL YEAR START UNIX TIME
type TestCase_getFiscalYearStartUnixTime = {
date: string;
expected: {
[fiscalYearStart: string]: {
unixTime: number;
unixTimeISO: string;
};
};
}
const TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME: TestCase_getFiscalYearStartUnixTime[] =
importTestData('test_cases_getFiscalYearStartUnixTime') as TestCase_getFiscalYearStartUnixTime[];
describe('getFiscalYearStartUnixTime', () => {
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME.forEach((testCase) => {
test(`returns correct start unix time for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => {
const testCaseUnixTime = moment(testCase.date).unix();
const startUnixTime = getFiscalYearStartUnixTime(testCaseUnixTime, testFiscalYearStart.value);
const expected = testCase.expected[testFiscalYearStart.id];
const unixTimeISO = formatUnixTimeISO(startUnixTime);
expect({ unixTime: startUnixTime, ISO: unixTimeISO }).toStrictEqual({ unixTime: expected!.unixTime, ISO: expected!.unixTimeISO });
});
});
});
});
// FISCAL YEAR END UNIX TIME
type TestCase_getFiscalYearEndUnixTime = {
date: string;
expected: {
[fiscalYearStart: string]: {
unixTime: number;
unixTimeISO: string;
};
};
}
const TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME: TestCase_getFiscalYearEndUnixTime[] =
importTestData('test_cases_getFiscalYearEndUnixTime') as TestCase_getFiscalYearEndUnixTime[];
describe('getFiscalYearEndUnixTime', () => {
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME.forEach((testCase) => {
test(`returns correct end unix time for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => {
const testCaseUnixTime = moment(testCase.date).unix();
const endUnixTime = getFiscalYearEndUnixTime(testCaseUnixTime, testFiscalYearStart.value);
const expected = testCase.expected[testFiscalYearStart.id];
const unixTimeISO = formatUnixTimeISO(endUnixTime);
expect({ unixTime: endUnixTime, ISO: unixTimeISO }).toStrictEqual({ unixTime: expected!.unixTime, ISO: expected!.unixTimeISO });
});
});
});
});
// GET FISCAL YEAR UNIX TIME RANGE
type TestCase_getFiscalYearTimeRangeFromUnixTime = {
date: string;
expected: {
[fiscalYearStart: string]: FiscalYearUnixTime[]
}
}
const TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE: TestCase_getFiscalYearTimeRangeFromUnixTime[] =
importTestData('test_cases_getFiscalYearTimeRangeFromUnixTime') as TestCase_getFiscalYearTimeRangeFromUnixTime[];
describe('getFiscalYearTimeRangeFromUnixTime', () => {
Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => {
TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE.forEach((testCase) => {
test(`returns correct fiscal year unix time range for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => {
const testCaseUnixTime = moment(testCase.date).unix();
const fiscalYearUnixTimeRange = getFiscalYearTimeRangeFromUnixTime(testCaseUnixTime, testFiscalYearStart.value);
expect(fiscalYearUnixTimeRange).toStrictEqual(testCase.expected[testFiscalYearStart.id]);
});
});
});
});
// GET ALL FISCAL YEAR START AND END UNIX TIMES
type TestCase_getAllFiscalYearsStartAndEndUnixTimes = {
startYearMonth: TextualYearMonth;
endYearMonth: TextualYearMonth;
fiscalYearStart: string;
fiscalYearStartId: string;
expected: FiscalYearUnixTime[]
}
const TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES: TestCase_getAllFiscalYearsStartAndEndUnixTimes[] =
importTestData('test_cases_getAllFiscalYearsStartAndEndUnixTimes') as TestCase_getAllFiscalYearsStartAndEndUnixTimes[];
describe('getAllFiscalYearsStartAndEndUnixTimes', () => {
TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES.forEach((testCase) => {
const fiscalYearStart = FiscalYearStart.parse(testCase.fiscalYearStart);
test(`returns correct fiscal year start and end unix times for ${getTestTitleFormatString(testCase.fiscalYearStartId, `${testCase.startYearMonth} to ${testCase.endYearMonth}`)}`, () => {
expect(fiscalYearStart).toBeDefined();
const fiscalYearStartAndEndUnixTimes = getAllFiscalYearsStartAndEndUnixTimes(testCase.startYearMonth, testCase.endYearMonth, fiscalYearStart?.value || 0);
// Convert results to include ISO strings for better test output
const resultWithISO = fiscalYearStartAndEndUnixTimes.map(data => ({
...data,
minUnixTimeISO: formatUnixTimeISO(data.minUnixTime),
maxUnixTimeISO: formatUnixTimeISO(data.maxUnixTime)
}));
// Convert expected to include ISO strings
const expectedWithISO = testCase.expected.map(data => ({
...data,
minUnixTimeISO: formatUnixTimeISO(data.minUnixTime),
maxUnixTimeISO: formatUnixTimeISO(data.maxUnixTime)
}));
expect(resultWithISO).toStrictEqual(expectedWithISO);
});
});
});
// GET FISCAL YEAR RANGE FROM YEAR
type TestCase_getFiscalYearTimeRangeFromYear = {
year: number;
fiscalYearStart: string;
expected: FiscalYearUnixTime;
}
const TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR: TestCase_getFiscalYearTimeRangeFromYear[] =
importTestData('test_cases_getFiscalYearTimeRangeFromYear') as TestCase_getFiscalYearTimeRangeFromYear[];
describe('getFiscalYearTimeRangeFromYear', () => {
TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR.forEach((testCase) => {
const fiscalYearStart = FiscalYearStart.parse(testCase.fiscalYearStart);
test(`returns correct fiscal year unix time range for input year integer ${testCase.year} and FY_START: ${testCase.fiscalYearStart}`, () => {
expect(fiscalYearStart).toBeDefined();
const fiscalYearRange = getFiscalYearTimeRangeFromYear(testCase.year, fiscalYearStart?.value || 0);
expect(fiscalYearRange).toStrictEqual(testCase.expected);
});
});
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { mean, median, percentile, sumMaxN } from '@/lib/math.ts';
describe('mean', () => {
it('should return zero for empty array', () => {
expect(mean([], item => item)).toBeCloseTo(0);
});
it('should return the average for positive values', () => {
expect(mean([1, 2, 3, 4], item => item)).toBeCloseTo(2.5);
});
it('should return the average for negative and positive values', () => {
expect(mean([-10, 0, 20], item => item)).toBeCloseTo(10 / 3);
});
});
describe('median', () => {
it('should return zero for empty sorted array', () => {
expect(median([], item => item)).toBeCloseTo(0);
});
it('should return the middle value for odd-length sorted array', () => {
expect(median([1, 3, 5], item => item)).toBeCloseTo(3);
});
it('should return the average of the two middle values for even-length sorted array', () => {
expect(median([1, 3, 5, 7], item => item)).toBeCloseTo(4);
});
});
describe('percentile', () => {
it('should return zero for empty sorted array', () => {
expect(percentile([], 0.5, item => item)).toBeCloseTo(0);
});
it('should return zero when percentile is smaller than zero', () => {
expect(percentile([1, 2, 3], -0.1, item => item)).toBeCloseTo(0);
});
it('should return zero when percentile is larger than one', () => {
expect(percentile([1, 2, 3], 1.1, item => item)).toBeCloseTo(0);
});
it('should return the minimum value for zero percentile', () => {
expect(percentile([5, 10, 15, 20], 0, item => item)).toBeCloseTo(5);
});
it('should return the maximum value for one percentile', () => {
expect(percentile([5, 10, 15, 20], 1, item => item)).toBeCloseTo(20);
});
it('should return the exact indexed value when percentile maps to an integer index', () => {
expect(percentile([10, 20, 30, 40, 50], 0.25, item => item)).toBeCloseTo(20);
});
it('should interpolate between neighboring values when percentile maps to a fractional index', () => {
expect(percentile([10, 20, 30, 40, 50, 60, 70, 80], 0.25, item => item)).toBeCloseTo(27.5);
});
});
describe('sumMaxN', () => {
it('should return zero for empty sorted array', () => {
expect(sumMaxN([], 3, item => item)).toBe(0);
});
it('should return zero when n is zero', () => {
expect(sumMaxN([1, 2, 3], 0, item => item)).toBe(0);
});
it('should return the sum of the largest n values', () => {
expect(sumMaxN([1, 2, 3, 4, 5], 2, item => item)).toBe(9);
});
it('should return the sum of all values when n is larger than array length', () => {
expect(sumMaxN([1, 2, 3, 4], 10, item => item)).toBe(10);
});
});
@@ -1,13 +1,12 @@
import fs from 'fs';
import path from 'path';
import { describe, expect, test } from '@jest/globals';
import { describe, expect, it } from 'vitest';
import { DEFAULT_CONTENT } from '@/locales/calendar/chinese/index.ts';
import { itemAndIndex, entries } from '@/core/base.ts';
import type { ChineseCalendarLocaleData } from '@/core/calendar.ts';
import {
type ChineseYearMonthDayInfo,
getChineseYearMonthAllDayInfos,
getChineseYearMonthDayInfo
} from '@/lib/calendar/chinese_calendar.ts';
@@ -45,12 +44,33 @@ const localeData: ChineseCalendarLocaleData = {
'Winter Solstice'
]
};
const ordinalSuffix = ['st', 'nd', 'rd'];
describe('getChineseYearMonthAllDayInfos', () => {
const lines: string[] = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').replace(/\r/g, '').split('\n');
function lunarMonthOrDayLabel(month: number, day: number): string {
return day === 1
? `${month}${ordinalSuffix[month - 1] ?? 'th'} Lunar Month`.toLowerCase()
: day.toString();
}
type PerDayEntry = {
gregorianDate: string;
gregorianYear: number;
gregorianMonth: number;
gregorianDay: number;
expectedChineseMonthOrDay: string;
expectedSolarTermName: string;
};
function parseCalendarDataFile(): {
allMonthChineseDays: Record<string, string[]>;
allMonthSolarTermNames: Record<string, string[]>;
perDayEntries: PerDayEntry[];
} {
const lines = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').replace(/\r/g, '').split('\n');
const allMonthChineseDays: Record<string, string[]> = {};
const allMonthSolarTermNames: Record<string, string[]> = {};
const perDayEntries: PerDayEntry[] = [];
let currentMonthChineseDays: string[] = [];
let currentMonthSolarTermNames: string[] = [];
let currentYear: number = 0;
@@ -66,16 +86,17 @@ describe('getChineseYearMonthAllDayInfos', () => {
const gregorianDateItems = gregorianDate.split('/');
const gregorianYear = parseInt(gregorianDateItems[0] as string, 10);
const gregorianMonth = parseInt(gregorianDateItems[1] as string, 10);
const gregorianDay = parseInt(gregorianDateItems[2] as string, 10);
const chineseDay = items[1] as string;
const solarTermName = items.length > 3 ? items[3] as string : '';
perDayEntries.push({ gregorianDate, gregorianYear, gregorianMonth, gregorianDay, expectedChineseMonthOrDay: chineseDay, expectedSolarTermName: solarTermName });
if (currentYear > 0 && currentMonth > 0 && (gregorianYear !== currentYear || gregorianMonth !== currentMonth)) {
allMonthChineseDays[`${currentYear}-${currentMonth}`] = currentMonthChineseDays;
allMonthSolarTermNames[`${currentYear}-${currentMonth}`] = currentMonthSolarTermNames;
currentMonthChineseDays = [];
currentMonthSolarTermNames = [];
currentYear = gregorianYear;
currentMonth = gregorianMonth;
} else if (currentYear === 0 && currentMonth === 0) {
@@ -92,27 +113,27 @@ describe('getChineseYearMonthAllDayInfos', () => {
allMonthChineseDays[`${currentYear}-${currentMonth}`] = currentMonthChineseDays;
allMonthSolarTermNames[`${currentYear}-${currentMonth}`] = currentMonthSolarTermNames;
return { allMonthChineseDays, allMonthSolarTermNames, perDayEntries };
}
const { allMonthChineseDays, allMonthSolarTermNames, perDayEntries } = parseCalendarDataFile();
describe('getChineseYearMonthAllDayInfos', () => {
for (const [yearMonth, monthChineseDays] of entries(allMonthChineseDays)) {
test(`returns correct chinese all dates in month for ${yearMonth}`, () => {
it(`should return correct chinese dates for all days in ${yearMonth}`, () => {
const [yearStr, monthStr] = yearMonth.split('-');
const year = parseInt(yearStr as string);
const month = parseInt(monthStr as string);
const expectedChineseMonthOrDays = monthChineseDays;
const expectedSolarTermNames = allMonthSolarTermNames[yearMonth] as string[];
const actualChineseDates: ChineseYearMonthDayInfo[] | undefined = getChineseYearMonthAllDayInfos({
year: year,
month1base: month
}, localeData);
const actualChineseDates = getChineseYearMonthAllDayInfos({ year, month1base: month }, localeData);
expect(actualChineseDates).toBeDefined();
if (actualChineseDates) {
for (const [actualChineseDate, index] of itemAndIndex(actualChineseDates)) {
const chineseMonthOrDay: string | undefined = actualChineseDate?.day === 1 ? `${actualChineseDate?.month}${ordinalSuffix[actualChineseDate?.month - 1] ?? 'th'} Lunar Month`.toLowerCase() : actualChineseDate?.day.toString();
expect(actualChineseDate).toBeDefined();
expect(chineseMonthOrDay).toBe(expectedChineseMonthOrDays[index]);
expect(lunarMonthOrDayLabel(actualChineseDate!.month, actualChineseDate!.day)).toBe(monthChineseDays[index]);
expect(actualChineseDate?.solarTermName).toBe(expectedSolarTermNames[index]);
}
}
@@ -121,32 +142,12 @@ describe('getChineseYearMonthAllDayInfos', () => {
});
describe('getChineseYearMonthDayInfo', () => {
const lines: string[] = fs.readFileSync(path.join(__dirname, 'chinese_calendar_all_data.txt'), 'utf8').replace(/\r/g, '').split('\n');
for (const line of lines) {
if (!line.trim() || line.startsWith('#')) {
continue;
}
const items = line.split('\t');
const gregorianDate = items[0] as string;
const gregorianDateItems = gregorianDate.split('/');
const gregorianYear = parseInt(gregorianDateItems[0] as string);
const gregorianMonth = parseInt(gregorianDateItems[1] as string);
const gregorianDay = parseInt(gregorianDateItems[2] as string);
const expectedChineseMonthOrDay = items[1] as string;
const expectedSolarTermName = items.length > 3 ? items[3] as string : '';
test(`returns correct chinese date for ${gregorianDate}`, () => {
const actualChineseDate: ChineseYearMonthDayInfo | undefined = getChineseYearMonthDayInfo({
year: gregorianYear,
month: gregorianMonth,
day: gregorianDay
}, localeData);
const actualChineseMonthOrDay: string | undefined = actualChineseDate?.day === 1 ? `${actualChineseDate?.month}${ordinalSuffix[actualChineseDate?.month - 1] ?? 'th'} Lunar Month`.toLowerCase() : actualChineseDate?.day.toString();
for (const { gregorianDate, gregorianYear, gregorianMonth, gregorianDay, expectedChineseMonthOrDay, expectedSolarTermName } of perDayEntries) {
it(`should return correct chinese date for ${gregorianDate}`, () => {
const actualChineseDate = getChineseYearMonthDayInfo({ year: gregorianYear, month: gregorianMonth, day: gregorianDay }, localeData);
expect(actualChineseDate).toBeDefined();
expect(actualChineseMonthOrDay).toBe(expectedChineseMonthOrDay.toLowerCase());
expect(lunarMonthOrDayLabel(actualChineseDate!.month, actualChineseDate!.day)).toBe(expectedChineseMonthOrDay.toLowerCase());
expect(actualChineseDate?.solarTermName).toBe(expectedSolarTermName);
});
}
+32 -1
View File
@@ -2,9 +2,10 @@ import {
type GenericNameValue,
type TypeAndName,
type TypeAndDisplayName,
entries,
keys,
keysIfValueEquals,
values
values,
} from '@/core/base.ts';
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
@@ -190,6 +191,21 @@ export function getObjectOwnFieldCount(object: object): number {
return count;
}
export function getObjectOwnFieldWithValueCount(object: object, value: unknown): number {
let count = 0;
if (!object || !isObject(object)) {
return count;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const _ of keysIfValueEquals(object, value)) {
count++;
}
return count;
}
export function replaceAll(value: string, originalValue: string, targetValue: string): string {
// Escape special characters in originalValue to safely use it in a regex pattern.
// This ensures that characters like . (dot), * (asterisk), +, ?, etc. are treated literally,
@@ -387,6 +403,21 @@ export function objectFieldToArrayItem(object: object): string[] {
return ret;
}
export function mapObjectToArray<V, R>(object: Record<string | number | symbol, V>, mapFunc: (value: V, key: string | number | symbol, index: number) => R): R[] {
const ret: R[] = [];
let index = 0;
for (const [key, value] of entries(object)) {
const mappedValue = mapFunc(value, key, index++);
if (isDefined(mappedValue)) {
ret.push(mappedValue);
}
}
return ret;
}
export function objectFieldWithValueToArrayItem<T>(object: Record<string, T>, value: T): string[] {
const ret: string[] = [];
+87 -4
View File
@@ -51,6 +51,11 @@ import {
NumeralSystem
} from '@/core/numeral.ts';
import {
WESTERNMOST_TIMEZONE_UTC_OFFSET,
EASTERNMOST_TIMEZONE_UTC_OFFSET,
} from '@/consts/timezone.ts';
import {
isFunction,
isDefined,
@@ -74,15 +79,12 @@ interface DateTimeFormatResult {
type DateTimeTokenFormatFunction = (d: MomentDateTime, options: DateTimeFormatOptions) => DateTimeFormatResult;
const westernmostTimezoneUtcOffset: number = -720; // Etc/GMT+12 (UTC-12:00)
const easternmostTimezoneUtcOffset: number = 840; // Pacific/Kiritimati (UTC+14:00)
function getFixedTimezoneName(utcOffset: number): string {
return `Fixed/Timezone${utcOffset}`;
}
(function initFixedTimezone(): void {
for (let utcOffset = westernmostTimezoneUtcOffset; utcOffset <= easternmostTimezoneUtcOffset; utcOffset += 15) {
for (let utcOffset = WESTERNMOST_TIMEZONE_UTC_OFFSET; utcOffset <= EASTERNMOST_TIMEZONE_UTC_OFFSET; utcOffset += 15) {
const timezoneName = getFixedTimezoneName(utcOffset);
if (moment.tz.zone(timezoneName)) {
@@ -254,6 +256,10 @@ class MomentDateTime implements DateTime {
return (this.instance.year() + '-' + (this.instance.month() + 1).toString().padStart(2, NumeralSystem.WesternArabicNumerals.digitZero)) as TextualYearMonth;
}
public getMaxDayOfGregorianCalendarMonth(): number {
return this.instance.clone().endOf('month').date();
}
public getWeekDay(): WeekDay {
return WeekDay.valueOf(this.instance.day()) as WeekDay;
}
@@ -890,6 +896,26 @@ export function getDayLastDateTimeBySpecifiedUnixTime(unixTime: number, utcOffse
return getDayFirstDateTimeBySpecifiedUnixTime(unixTime, utcOffset).add(1, 'days').subtract(1, 'seconds');
}
export function getBillingCycleFirstUnixTimeBySpecifiedUnixTime(unixTime: number, statementDate: number, utcOffset?: number): DateTime {
let date = moment.unix(unixTime);
if (isNumber(utcOffset)) {
date = date.tz(getFixedTimezoneName(utcOffset));
}
if (date.date() > statementDate) {
date = date.set({ date: statementDate + 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
} else {
date = date.set({ date: statementDate, hour: 0, minute: 0, second: 0, millisecond: 0 }).add(-1, 'months').add(1, 'days');
}
return MomentDateTime.of(date);
}
export function getBillingCycleLastUnixTimeBySpecifiedUnixTime(unixTime: number, statementDate: number, utcOffset?: number): DateTime {
return getBillingCycleFirstUnixTimeBySpecifiedUnixTime(unixTime, statementDate, utcOffset).add(1, 'months').subtract(1, 'seconds');
}
export function getYearFirstUnixTime(year: number): number {
return moment().set({ year: year, month: 0, date: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }).unix();
}
@@ -1106,6 +1132,41 @@ export function getAllMonthsStartAndEndUnixTimes(startYearMonth: Year0BasedMonth
return allYearMonthTimes;
}
export function getAllBillingCyclesStartAndEndUnixTimes(startUnixTime: number, endUnixTime: number, statementDate: number): YearMonthUnixTime[] {
const allYearMonthTimes: YearMonthUnixTime[] = [];
if (!startUnixTime || !endUnixTime) {
return allYearMonthTimes;
}
let unixTime: number = startUnixTime;
while (unixTime <= endUnixTime) {
const currentDateTime = parseDateTimeFromUnixTime(unixTime);
let currentBillingCycleMinDateTime: DateTime;
if (currentDateTime.getGregorianCalendarDay() > statementDate) {
const currentMonthMinDateTime = getMonthFirstDateTimeBySpecifiedUnixTime(unixTime);
currentBillingCycleMinDateTime = currentMonthMinDateTime.add(statementDate, 'days');
} else {
const currentMonthMinDateTime = getMonthFirstDateTimeBySpecifiedUnixTime(unixTime);
const previousMonthMinDateTime = currentMonthMinDateTime.add(-1, 'months');
currentBillingCycleMinDateTime = previousMonthMinDateTime.add(statementDate, 'days');
}
const currentBillingCycleMaxDateTime = currentBillingCycleMinDateTime.add(1, 'months').subtract(1, 'seconds');
const yearMonth: Year0BasedMonth = {
year: currentBillingCycleMaxDateTime.getGregorianCalendarYear(),
month0base: currentBillingCycleMaxDateTime.getGregorianCalendarMonth() - 1
};
allYearMonthTimes.push(YearMonthUnixTime.of(yearMonth, currentBillingCycleMinDateTime.getUnixTime(), currentBillingCycleMaxDateTime.getUnixTime()));
unixTime = currentBillingCycleMaxDateTime.getUnixTime() + 1;
}
return allYearMonthTimes;
}
export function getAllDaysStartAndEndUnixTimes(startUnixTime: number, endUnixTime: number): YearMonthDayUnixTime[] {
const allYearMonthDayTimes: YearMonthDayUnixTime[] = [];
@@ -1372,6 +1433,28 @@ export function getDateRangeByBillingCycleDateType(dateType: number, firstDayOfW
};
}
export function getDateRangeByLastReconciledTimeRangeDateType(dateType: number, lastReconciledTime: number | undefined | null): TimeRangeAndDateType | null {
let maxTime = 0;
let minTime = 0;
if (dateType === DateRange.SinceLastReconciledTime.type) { // Since Last Reconciled Time
if (lastReconciledTime) {
maxTime = getTodayLastUnixTime();
minTime = lastReconciledTime;
} else {
return null;
}
} else {
return null;
}
return {
dateType: dateType,
maxTime: maxTime,
minTime: minTime
};
}
export function getRecentMonthDateRanges(monthCount: number): RecentMonthDateRange[] {
const recentDateRanges: RecentMonthDateRange[] = [];
const thisMonthFirstUnixTime = getThisMonthFirstUnixTime();
+2 -1
View File
@@ -1,3 +1,4 @@
import { AMOUNT_FACTOR } from '@/consts/numeral.ts';
import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '../consts/transaction.ts';
import { replaceAll } from './common.ts';
@@ -10,7 +11,7 @@ type OperatorAndParenthesis = Operator | '(' | ')';
const maxAllowedDecimalCount = 6;
const normalizeFactor: number = 1000000;
const normalizedDecimalsMaxZeroString: string = '000000';
const normalizedNumberToAmountFactor: number = 10000; // 1000000 / 100
const normalizedNumberToAmountFactor: number = normalizeFactor / AMOUNT_FACTOR;
const operatorPriority: Record<Operator, number> = {
'+': 1,
+166
View File
@@ -0,0 +1,166 @@
import { reversed } from '@/core/base.ts';
export function mean<T>(values: T[], valueFn: (item: T) => number): number {
if (values.length < 1) {
return 0;
}
let sum: number = 0;
for (const item of values) {
sum += valueFn(item);
}
return sum / values.length;
}
export function median<T>(sortedValues: T[], valueFn: (item: T) => number): number {
if (sortedValues.length < 1) {
return 0;
}
const mid: number = Math.floor(sortedValues.length / 2);
if (sortedValues.length % 2 === 0) {
return (valueFn(sortedValues[mid - 1] as T) + valueFn(sortedValues[mid] as T)) / 2;
} else {
return valueFn(sortedValues[mid] as T);
}
}
export function percentile<T>(sortedValues: T[], percentile: number, valueFn: (item: T) => number): number {
if (sortedValues.length < 1 || percentile < 0 || percentile > 1) {
return 0;
}
const index: number = (sortedValues.length - 1) * percentile + 1;
const indexFloor: number = Math.floor(index);
const indexCeil: number = Math.ceil(index);
if (indexFloor === indexCeil) {
return valueFn(sortedValues[indexFloor - 1] as T);
} else {
const value1: number = valueFn(sortedValues[indexFloor - 1] as T);
const value2: number = valueFn(sortedValues[indexCeil - 1] as T);
return value1 + (index - indexFloor) * (value2 - value1);
}
}
export function sumMaxN<T>(sortedValues: T[], n: number, valueFn: (item: T) => number): number {
if (sortedValues.length < 1 || n <= 0) {
return 0;
}
let sum: number = 0;
const count: number = Math.min(n, sortedValues.length);
const startIndex: number = sortedValues.length - count;
for (let i = sortedValues.length - 1; i >= startIndex; i--) {
sum += valueFn(sortedValues[i] as T);
}
return sum;
}
export function cumulativePercentage<T>(sortedValues: T[], percentageThreshold: number, totalValue: number, valueFn: (item: T) => number): number {
if (sortedValues.length < 1 || percentageThreshold < 0 || percentageThreshold > 1) {
return 0;
}
const thresholdValue: number = percentageThreshold * totalValue;
let cumulativeValue: number = 0;
let cumulativeCount: number = 0;
for (const item of reversed(sortedValues)) {
cumulativeValue += valueFn(item);
cumulativeCount++;
if (cumulativeValue >= thresholdValue) {
return 100.0 * cumulativeCount / sortedValues.length;
}
}
return 0;
}
export function meanAbsoluteDeviation<T>(values: T[], meanValue: number, valueFn: (item: T) => number): number {
if (values.length < 1) {
return 0;
}
let sumOfAbsoluteDifferences: number = 0;
for (const item of values) {
const difference: number = Math.abs(valueFn(item) - meanValue);
sumOfAbsoluteDifferences += difference;
}
return sumOfAbsoluteDifferences / values.length;
}
export function medianAbsoluteDeviation<T>(sortedValues: T[], medianValue: number, valueFn: (item: T) => number): number {
if (sortedValues.length < 1) {
return 0;
}
const absoluteDeviations: number[] = sortedValues.map(item => Math.abs(valueFn(item) - medianValue));
absoluteDeviations.sort((a, b) => a - b);
return median(absoluteDeviations, x => x);
}
export function varianceAndStandardDeviation<T>(values: T[], meanValue: number, valueFn: (item: T) => number): { variance: number; standardDeviation: number } {
if (values.length < 1) {
return { variance: 0, standardDeviation: 0 };
}
let sumOfSquaredDifferences: number = 0;
for (const item of values) {
const difference: number = valueFn(item) - meanValue;
sumOfSquaredDifferences += difference * difference;
}
const variance: number = sumOfSquaredDifferences / values.length;
const standardDeviation: number = Math.sqrt(variance);
return { variance, standardDeviation };
}
export function coefficientOfVariation(standardDeviation: number, meanValue: number): number | undefined {
if (meanValue === 0) {
return undefined;
}
return standardDeviation / meanValue;
}
export function skewness<T>(values: T[], meanValue: number, standardDeviation: number, valueFn: (item: T) => number): number {
if (values.length < 1 || standardDeviation === 0) {
return 0;
}
let sumOfCubedDifferences: number = 0;
for (const item of values) {
const difference: number = valueFn(item) - meanValue;
sumOfCubedDifferences += Math.pow(difference, 3);
}
return sumOfCubedDifferences / (values.length * Math.pow(standardDeviation, 3));
}
export function kurtosis<T>(values: T[], meanValue: number, variance: number, valueFn: (item: T) => number): number {
if (values.length < 1 || variance === 0) {
return 0;
}
let sumOfQuarticDifferences: number = 0;
for (const item of values) {
const difference: number = valueFn(item) - meanValue;
sumOfQuarticDifferences += Math.pow(difference, 4);
}
return sumOfQuarticDifferences / (values.length * Math.pow(variance, 2));
}
+10 -17
View File
@@ -6,20 +6,12 @@ import {
DigitGroupingSymbol
} from '@/core/numeral.ts';
import { AMOUNT_FACTOR } from '@/consts/numeral.ts';
import { DEFAULT_DECIMAL_NUMBER_COUNT, MAX_SUPPORTED_DECIMAL_NUMBER_COUNT, DISPLAY_HIDDEN_AMOUNT } from '@/consts/numeral.ts';
import { isDefined, isString, isNumber, replaceAll, removeAll } from './common.ts';
export function sumAmounts(amounts: number[]): number {
let sum = 0;
for (const amount of amounts) {
sum += amount;
}
return sum;
}
export function appendDigitGroupingSymbolAndDecimalSeparator(textualNumber: string, options: NumberFormatOptions): string {
if (!textualNumber) {
return textualNumber;
@@ -125,7 +117,7 @@ export function parseAmount(str: string, options: NumberFormatOptions): number {
let decimalSeparatorPos = str.indexOf(decimalSeparator);
if (decimalSeparatorPos < 0) {
return sign * numeralSystem.parseInt(str) * 100;
return sign * numeralSystem.parseInt(str) * AMOUNT_FACTOR;
} else if (decimalSeparatorPos === 0) {
str = numeralSystem.digitZero + str;
decimalSeparatorPos++;
@@ -135,13 +127,13 @@ export function parseAmount(str: string, options: NumberFormatOptions): number {
const decimals = str.substring(decimalSeparatorPos + 1, str.length);
if (decimals.length < 1) {
return sign * numeralSystem.parseInt(integer) * 100;
return sign * numeralSystem.parseInt(integer) * AMOUNT_FACTOR;
} else if (decimals.length === 1) {
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals) * 10;
return sign * numeralSystem.parseInt(integer) * AMOUNT_FACTOR + sign * numeralSystem.parseInt(decimals) * AMOUNT_FACTOR / 10;
} else if (decimals.length === 2) {
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals);
return sign * numeralSystem.parseInt(integer) * AMOUNT_FACTOR + sign * numeralSystem.parseInt(decimals);
} else {
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals.substring(0, 2));
return sign * numeralSystem.parseInt(integer) * AMOUNT_FACTOR + sign * numeralSystem.parseInt(decimals.substring(0, 2));
}
}
@@ -262,9 +254,10 @@ export function formatPercent(value: number, precision: number, lowPrecisionValu
export function getAmountWithDecimalNumberCount(amount: number, decimalNumberCount: number): number {
if (decimalNumberCount === 0) {
return Math.trunc(amount / 100) * 100;
return Math.trunc(amount / AMOUNT_FACTOR) * AMOUNT_FACTOR;
} else if (decimalNumberCount === 1) {
return Math.trunc(amount / 10) * 10;
const factor = AMOUNT_FACTOR / 10;
return Math.trunc(amount / factor) * factor;
}
return amount;

Some files were not shown because too many files have changed in this diff Show More