Compare commits
434 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6787d0591e | |||
| d78dada5ec | |||
| 74844b9a99 | |||
| a29ff0d553 | |||
| 6632dd64b3 | |||
| a8c912c4c2 | |||
| 2a02816127 | |||
| 66b950b4aa | |||
| 639bd9c5cd | |||
| 2bf8c0b501 | |||
| 847d5121aa | |||
| ab6e89594e | |||
| 0662427cec | |||
| d3b283f623 | |||
| 385d97ba15 | |||
| 09574f1c75 | |||
| 34247be52c | |||
| 56b1e1f565 | |||
| 7c6a3081ee | |||
| beeeb1c059 | |||
| 47f70098df | |||
| 4e6b708834 | |||
| a359b07ef3 | |||
| bb0524c559 | |||
| d70ea1987a | |||
| 001de8a1e0 | |||
| f045e8ffcd | |||
| f8be1222d6 | |||
| 8848fe8b33 | |||
| 32524dac56 | |||
| a50ecf4d9c | |||
| 98fda4e5d5 | |||
| f0c111f02a | |||
| bcc36e1533 | |||
| 872639fefa | |||
| e83b959930 | |||
| 3f8de39683 | |||
| 9430f57a0b | |||
| 703ceb44e4 | |||
| 3954db9b99 | |||
| 7abb5972eb | |||
| 377a4899b7 | |||
| d769e833e7 | |||
| 9786f96fe5 | |||
| bd2a672c12 | |||
| 96cb45dd45 | |||
| cf370c083b | |||
| 43e1780dc8 | |||
| 6aac810450 | |||
| f14c283a83 | |||
| e78b2cafb1 | |||
| a9eaf011cd | |||
| 7fca519fd9 | |||
| a9a37b0c97 | |||
| 8f55bd0df1 | |||
| 85e88949c4 | |||
| 80bcebbf66 | |||
| 7cae873830 | |||
| 7a1b27927f | |||
| 306da60752 | |||
| 4274b90b1e | |||
| 0b5721671d | |||
| 30575d15d0 | |||
| 0ca2f8b4a7 | |||
| 2e01e5530c | |||
| 13cc6a2cf0 | |||
| 35ba5dcc9f | |||
| ab58109e5e | |||
| 18a6d25ed6 | |||
| a0e3a269a0 | |||
| 1658d0758c | |||
| 6f0c59bba4 | |||
| 1e98a0df55 | |||
| fa77d3e837 | |||
| a21bc7aad7 | |||
| e665fac956 | |||
| e60c633e56 | |||
| a444526743 | |||
| b65a246fcb | |||
| c5e8a50033 | |||
| 6021c24da9 | |||
| 0e4cd10376 | |||
| f2c043a299 | |||
| 596787b998 | |||
| bb3a0c4444 | |||
| 624b9cb20b | |||
| 5a6c25d616 | |||
| eb178e7bed | |||
| 373d71c124 | |||
| 6618cfeceb | |||
| 58c1382570 | |||
| 721eb122bd | |||
| 95205d2f1d | |||
| b6efa91879 | |||
| 29d7ee09c8 | |||
| 68cb5bc523 | |||
| bd96b2398a | |||
| 1579882475 | |||
| b077b99806 | |||
| 9797e7e58f | |||
| 00f62fd608 | |||
| 833e767e6c | |||
| 3e7b3297aa | |||
| 8e170a69e8 | |||
| 00d6f5d473 | |||
| 3c363788d8 | |||
| cc920cff9a | |||
| 5d78d56f0c | |||
| b9b47c4428 | |||
| a5382e9fdd | |||
| 376f5b2650 | |||
| 61c5f75006 | |||
| 9a6148fe6e | |||
| ff6a558e96 | |||
| 3ad45bebb7 | |||
| 6b152bd778 | |||
| aacde2dfde | |||
| 6971eccb22 | |||
| a5e7c483ef | |||
| 319f97bf9e | |||
| da31a67c52 | |||
| af355e5b85 | |||
| ca9fe264b4 | |||
| c07e937702 | |||
| 371b88c6cd | |||
| 782bc11950 | |||
| 50c3fee7dc | |||
| 51c4e06e59 | |||
| 2a84f44f2c | |||
| 4878c7258d | |||
| e2a4e0cb3f | |||
| fd3457af84 | |||
| 8c0a9062a2 | |||
| 10d301aa3c | |||
| d7193847c5 | |||
| 8ea079679c | |||
| 95c7b498ff | |||
| 1156499b05 | |||
| e92aaffc94 | |||
| 8dc38912c9 | |||
| d228bf12bb | |||
| 7dff8a2ed5 | |||
| 814fe02949 | |||
| da702d6316 | |||
| fd909023f9 | |||
| c94c455b8b | |||
| d39b0ee077 | |||
| 99a2f40a4e | |||
| acaad355ed | |||
| ad4b351a32 | |||
| 2902fae1df | |||
| a0b9ca7fae | |||
| 05d8f8b9ab | |||
| d074a9d54a | |||
| d0274013cf | |||
| 1e0169a9b7 | |||
| c619d2ecad | |||
| a27a2556aa | |||
| 8207373a05 | |||
| 986fab9cbf | |||
| 2025551f3c | |||
| 3d934ab018 | |||
| 6a59ed0984 | |||
| 8fce3f2bcc | |||
| eca0574e41 | |||
| fa044f5972 | |||
| 6d758f338b | |||
| 28322bad5e | |||
| a9805b8fff | |||
| eb16b7fbb8 | |||
| 70428b6c96 | |||
| 85557c2879 | |||
| 5bf7f77520 | |||
| 3cdc7c947f | |||
| 84fc6b2ffb | |||
| e7612f6f0c | |||
| 58097331da | |||
| e053528abf | |||
| 568abb6e03 | |||
| 852c7899b5 | |||
| f4998da4cd | |||
| 9d9e6ef9bd | |||
| fb367174b6 | |||
| 87566010be | |||
| 2f7cdfd786 | |||
| 559e8259be | |||
| 929d3febb0 | |||
| 92df98c3fb | |||
| 019c993313 | |||
| 3c370b7ac7 | |||
| 1afd811aa8 | |||
| 0a999d56c7 | |||
| 4f6988d775 | |||
| 9f2bbe527e | |||
| 965be837a3 | |||
| f5f8b9a145 | |||
| f3b6c1266d | |||
| c675057ab1 | |||
| 20e95e35aa | |||
| f22666e756 | |||
| 3567ac170a | |||
| 260bd952d4 | |||
| e294f04b04 | |||
| 27fa5625be | |||
| 62623e6a23 | |||
| 9df436d31e | |||
| c03f74154b | |||
| 749bdfd164 | |||
| 6878d5260d | |||
| 4f21762533 | |||
| adebc96637 | |||
| 3a50e6d2de | |||
| b09b66adc3 | |||
| 6ef42a9303 | |||
| 922c338387 | |||
| dc4310c301 | |||
| 0b7fd647e6 | |||
| 081b270f04 | |||
| 29c09cb10a | |||
| cd2e6c1aae | |||
| d3e6756c22 | |||
| 7d31812055 | |||
| 8ce871e9bb | |||
| 30125f0faa | |||
| f4ea9a85f0 | |||
| 593e123610 | |||
| 4e8eddd868 | |||
| 041dbcb5c7 | |||
| 215a0163b6 | |||
| a38867ce01 | |||
| 9026c526f8 | |||
| c1f94a4499 | |||
| 2be329974e | |||
| abb26ac410 | |||
| c5f03165bc | |||
| 41452ac20b | |||
| a285707b53 | |||
| cc9a5eea36 | |||
| 89d5cc31af | |||
| f0e11e952c | |||
| 0f8431ff5c | |||
| 659b819011 | |||
| 6a62cfdef7 | |||
| f2ebd751d4 | |||
| 1414f54a12 | |||
| c3265c5bf6 | |||
| 13e322bb57 | |||
| 5cacfc8daf | |||
| 9bbe4d2dcf | |||
| b517409229 | |||
| 75a96e871a | |||
| 395f7dfd63 | |||
| b166f6ff56 | |||
| eb2b6d1002 | |||
| 6cb045453a | |||
| ffae9e81a7 | |||
| dc59d3954a | |||
| 7b26eb50bf | |||
| c0ab1ad793 | |||
| c73dcb51e4 | |||
| 8c7fc0fef9 | |||
| 76a2e24d06 | |||
| 3e6a054913 | |||
| 89b233e51b | |||
| 61f26e060e | |||
| 5649bb243d | |||
| ea90e97f92 | |||
| 3c624188d1 | |||
| 04f373e931 | |||
| b2e36a24fd | |||
| 8da3d2aa35 | |||
| 25c8b9baf8 | |||
| 1555052e1d | |||
| b1fbf91d6e | |||
| 5dfac0c085 | |||
| cb142a65f3 | |||
| b0a9b2366e | |||
| ed897d4105 | |||
| 2b71723ba1 | |||
| 60ba3b7977 | |||
| e0198da52c | |||
| 6365805715 | |||
| 5e7e3696bf | |||
| 166fae425d | |||
| f56bef40d8 | |||
| ad1eec7d47 | |||
| 5b241d2547 | |||
| 92a626fb21 | |||
| 5171f23c09 | |||
| 6dc0ebcac6 | |||
| 49f1f3c86b | |||
| 53ab441486 | |||
| 0e422b5a8f | |||
| 4f51480af9 | |||
| fb8fbbcf70 | |||
| 5256eff88d | |||
| 7c40157cba | |||
| 454e97c9f1 | |||
| 41d34af4c7 | |||
| a13f5bfb10 | |||
| da06fe4a7b | |||
| 061ea6aab4 | |||
| a19cc81391 | |||
| 871164b969 | |||
| 16fa77eb09 | |||
| a46399cbaf | |||
| a9e50d29d3 | |||
| e7d7f217a9 | |||
| 000c2b9ab0 | |||
| 37fdb161ea | |||
| 2d923bbdc9 | |||
| 07c55de024 | |||
| 30c463627a | |||
| 5eec635146 | |||
| 229d9c76c3 | |||
| 0119eadc14 | |||
| c1c656ab7e | |||
| e4a50bcd60 | |||
| d18e6df1c0 | |||
| af9aa726f4 | |||
| 27f8c90dae | |||
| b9a3c384d9 | |||
| eed7085756 | |||
| abb0c2ad16 | |||
| 9f7b40381c | |||
| ad9a390b58 | |||
| 5525635df1 | |||
| 863e0205ff | |||
| 2560a70e5e | |||
| b638a73e4d | |||
| 0a9cea4df3 | |||
| 15a98c3eac | |||
| 493c16087d | |||
| dd155a0f63 | |||
| 9ce1c8d397 | |||
| 3040435c06 | |||
| 685f93f05e | |||
| d465d9da1a | |||
| 50c2766014 | |||
| 4e1cbf13c6 | |||
| a26397131d | |||
| 7659e8f0f7 | |||
| 90b608bdc6 | |||
| fffe2a1ccb | |||
| fd7706de6d | |||
| d0a5c93e49 | |||
| 263bf08f34 | |||
| e050f30efa | |||
| c2b1adf588 | |||
| 647cd3c33f | |||
| 8fdbb39ee4 | |||
| ee029294f1 | |||
| 563e328ce3 | |||
| 8f543d7a84 | |||
| 62e09190f3 | |||
| 50c774fd78 | |||
| 10e4bcc723 | |||
| 964ad6d046 | |||
| 56fb76017d | |||
| 5a9141e10c | |||
| db94282207 | |||
| 9f6446c30c | |||
| d570ce361d | |||
| 868fcf2c5a | |||
| dd35a85316 | |||
| 5003f8b3a2 | |||
| d044f938e3 | |||
| e549779164 | |||
| e2f2b325a6 | |||
| 9860c1db54 | |||
| 7d820f5b88 | |||
| 61d6e5643c | |||
| b444de591a | |||
| 21c86c9dfa | |||
| 8e70754533 | |||
| 4270d74338 | |||
| db506fa992 | |||
| c1b06eaa6f | |||
| 6bd1d09fa8 | |||
| 70da228dcc | |||
| 9888efe437 | |||
| 65756b62a5 | |||
| 59a0d593d4 | |||
| d519b80b61 | |||
| e92725f38b | |||
| ec0cb0bbb7 | |||
| a4b26374f4 | |||
| dcac6a4bb0 | |||
| dd6eecb0c2 | |||
| fec100a273 | |||
| 8f944b1b46 | |||
| 69498003d8 | |||
| e019f557ff | |||
| 4b5611ef6c | |||
| ca44b2cc2c | |||
| 10e0972d79 | |||
| 28908d81a3 | |||
| 0503a50754 | |||
| 65a92042d6 | |||
| f554fdefd3 | |||
| bdbd4d5302 | |||
| 3ee1683349 | |||
| 3a7ad429c2 | |||
| 89bd055f02 | |||
| 835b3b7b8b | |||
| 934f90cdff | |||
| 92cc683b8e | |||
| 80d548e8bd | |||
| 7ec1efb85d | |||
| f5945a788f | |||
| 2d0e2e0cca | |||
| bff6ca7e9d | |||
| 06b4960984 | |||
| 2fe393204b | |||
| 876950a84e | |||
| 6292ef9dfb | |||
| 798fb8f937 | |||
| f6dd4c03c3 | |||
| f87fbddef7 | |||
| aa2e10440d | |||
| 34b0b793ba | |||
| 1f159bf826 | |||
| b8253b6dcc | |||
| 79fd9070e4 | |||
| 7b96cd0447 | |||
| 01bc9becc0 | |||
| 9a009b73dc | |||
| fe35cbae49 | |||
| c3a880e5f5 | |||
| 1c906113ab | |||
| 6f3dcd958d | |||
| 7a9f4cd64f | |||
| 9a67af7c55 | |||
| 501de6ffef |
@@ -1,16 +0,0 @@
|
||||
module.exports = {
|
||||
'root': true,
|
||||
'env': {
|
||||
'node': true
|
||||
},
|
||||
'extends': [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-essential'
|
||||
],
|
||||
'rules': {
|
||||
'vue/no-use-v-if-with-v-for': 'off',
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true,
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Deploy Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Execute custom script
|
||||
run: |
|
||||
cat >> deploy.sh <<EOF
|
||||
#!/bin/sh
|
||||
${{ vars.CUSTOM_DEPLOY_SCRIPTS }}
|
||||
EOF
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
||||
@@ -24,10 +24,12 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up the environment
|
||||
run: |
|
||||
@@ -44,7 +46,7 @@ jobs:
|
||||
chmod +x docker/custom-frontend-pre-setup.sh
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
@@ -52,5 +54,6 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
RELEASE_BUILD=1
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
||||
@@ -24,10 +24,12 @@ jobs:
|
||||
type=sha,format=short,prefix=SNAPSHOT-
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up the environment
|
||||
run: |
|
||||
@@ -44,11 +46,13 @@ jobs:
|
||||
chmod +x docker/custom-frontend-pre-setup.sh
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
platforms: ${{ vars.BUILD_SNAPSHOT_PLATFORMS }}
|
||||
push: true
|
||||
build-args: |
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
||||
@@ -24,19 +24,21 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
@@ -48,5 +50,6 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
RELEASE_BUILD=1
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
||||
@@ -23,19 +23,21 @@ jobs:
|
||||
type=raw,value=latest-snapshot
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
@@ -45,5 +47,7 @@ jobs:
|
||||
linux/arm/v7
|
||||
linux/arm/v6
|
||||
push: true
|
||||
build-args: |
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -11,18 +11,20 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Build
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
push: false
|
||||
build-args: |
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
|
||||
+5
-3
@@ -1,7 +1,9 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.22.8-alpine3.20 AS be-builder
|
||||
FROM golang:1.24.0-alpine3.21 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG SKIP_TESTS
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
ENV SKIP_TESTS=$SKIP_TESTS
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
COPY . .
|
||||
RUN docker/backend-build-pre-setup.sh
|
||||
@@ -9,7 +11,7 @@ RUN apk add git gcc g++ libc-dev
|
||||
RUN ./build.sh backend
|
||||
|
||||
# Build frontend files
|
||||
FROM --platform=$BUILDPLATFORM node:20.18.0-alpine3.20 AS fe-builder
|
||||
FROM --platform=$BUILDPLATFORM node:22.14.0-alpine3.21 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
@@ -19,7 +21,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.20.3
|
||||
FROM alpine:3.21.3
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2024 MaysWind (i@mayswind.net)
|
||||
Copyright (c) 2020-2025 MaysWind (i@mayswind.net)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://github.com/mayswind/ezbookkeeping/releases)
|
||||
|
||||
## Introduction
|
||||
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including sqlite and mysql. With docker, you can just deploy it via one command without complicated configuration.
|
||||
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including SQLite, MySQL and PostgreSQL. With docker, you can just deploy it via one command without complicated configuration.
|
||||
|
||||
Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
|
||||
|
||||
@@ -31,7 +31,7 @@ Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-dem
|
||||
7. Multi-language support
|
||||
8. Two-factor authentication
|
||||
9. Application lock (PIN code / WebAuthn)
|
||||
10. Data import & export
|
||||
10. Data export & import (OFX, QFX, QIF, IIF, CSV, GnuCash, FireFly III, etc.)
|
||||
|
||||
## Screenshots
|
||||
### Desktop Version
|
||||
@@ -88,7 +88,7 @@ You can also build docker image, make sure you have [docker](https://www.docker.
|
||||
|
||||
## Documents
|
||||
1. [English](http://ezbookkeeping.mayswind.net)
|
||||
1. [简体中文 (Simplified Chinese)](http://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
1. [中文 (简体)](http://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
|
||||
## License
|
||||
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
set "TYPE="
|
||||
set "NO_LINT=0"
|
||||
set "NO_TEST=0"
|
||||
set "SKIP_TESTS=%SKIP_TESTS%"
|
||||
set "RELEASE=%RELEASE_BUILD%"
|
||||
set "RELEASE_TYPE=unknown"
|
||||
set "VERSION="
|
||||
@@ -56,7 +57,7 @@ goto :pre_parse_args
|
||||
echo /r, --release Build release (The script will use environment variable "RELEASE_BUILD" to detect whether this is release building by default)
|
||||
echo /o, --output ^<filename^> Package file name (For "package" type only)
|
||||
echo --no-lint Do not execute lint check before building
|
||||
echo --no-test Do not execute unit testing before building
|
||||
echo --no-test Do not execute unit testing before building (You can use environment variable "SKIP_TESTS" to skip specified tests)
|
||||
echo /h, --help Show help
|
||||
goto :eof
|
||||
|
||||
@@ -139,7 +140,13 @@ goto :pre_parse_args
|
||||
if "%NO_TEST%"=="0" (
|
||||
echo Executing backend unit testing...
|
||||
call go clean -cache
|
||||
call go test .\... -v
|
||||
|
||||
if "%SKIP_TESTS%"=="" (
|
||||
call go test .\... -v
|
||||
) else (
|
||||
echo (Skip unit test "%SKIP_TESTS%")
|
||||
call go test .\... -v -skip "%SKIP_TESTS%"
|
||||
)
|
||||
|
||||
if !errorlevel! neq 0 (
|
||||
call :echo_red "Error: Failed to pass unit testing"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
TYPE=""
|
||||
NO_LINT="0"
|
||||
NO_TEST="0"
|
||||
SKIP_TESTS="${SKIP_TESTS}"
|
||||
RELEASE=${RELEASE_BUILD:-"0"}
|
||||
RELEASE_TYPE="unknown"
|
||||
VERSION=""
|
||||
@@ -43,7 +44,7 @@ Options:
|
||||
-o, --output <filename> Package file name (For "package" type only)
|
||||
-t, --tag Docker tag (For "docker" type only)
|
||||
--no-lint Do not execute lint check before building
|
||||
--no-test Do not execute unit testing before building
|
||||
--no-test Do not execute unit testing before building (You can use environment variable "SKIP_TESTS" to skip specified tests)
|
||||
-h, --help Show help
|
||||
EOF
|
||||
}
|
||||
@@ -137,7 +138,13 @@ build_backend() {
|
||||
if [ "$NO_TEST" = "0" ]; then
|
||||
echo "Executing backend unit testing..."
|
||||
go clean -cache
|
||||
go test ./... -v
|
||||
|
||||
if [ -z "$SKIP_TESTS" ]; then
|
||||
go test ./... -v
|
||||
else
|
||||
echo "(Skip unit test \"$SKIP_TESTS\")"
|
||||
go test ./... -v -skip "$SKIP_TESTS"
|
||||
fi
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo_red "Error: Failed to pass unit testing"
|
||||
|
||||
+172
-1
@@ -114,6 +114,63 @@ var UserData = &cli.Command{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-set-restrict-features",
|
||||
Usage: "Set restrictions of user features",
|
||||
Action: bindAction(setUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-add-restrict-features",
|
||||
Usage: "Add restrictions of user features",
|
||||
Action: bindAction(addUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-remove-restrict-features",
|
||||
Usage: "Remove restrictions of user features",
|
||||
Action: bindAction(removeUserFeatureRestriction),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "features",
|
||||
Aliases: []string{"t"},
|
||||
Required: true,
|
||||
Usage: "Specific feature types (feature types separated by commas)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-resend-verify-email",
|
||||
Usage: "Resend user verify email",
|
||||
@@ -192,6 +249,19 @@ var UserData = &cli.Command{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-new",
|
||||
Usage: "Create new session for user",
|
||||
Action: bindAction(createNewUserToken),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"n"},
|
||||
Required: true,
|
||||
Usage: "Specific user name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-clear",
|
||||
Usage: "Clear user all sessions",
|
||||
@@ -423,6 +493,81 @@ func disableUser(c *core.CliContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
err = clis.UserData.SetUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.setUserFeatureRestriction] error occurs when setting user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.setUserFeatureRestriction] user \"%s\" has been set new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
|
||||
if featureRestriction < 1 {
|
||||
log.CliErrorf(c, "[user_data.addUserFeatureRestriction] nothing has been modified")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = clis.UserData.AddUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.addUserFeatureRestriction] error occurs when adding user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.addUserFeatureRestriction] user \"%s\" has been add new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeUserFeatureRestriction(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
|
||||
|
||||
if featureRestriction < 1 {
|
||||
log.CliErrorf(c, "[user_data.removeUserFeatureRestriction] nothing has been modified")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = clis.UserData.RemoveUserFeatureRestrictions(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.removeUserFeatureRestriction] error occurs when removing user feature restriction")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.removeUserFeatureRestriction] user \"%s\" has been removed new feature restriction", username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resendUserVerifyEmail(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
@@ -549,6 +694,27 @@ func listUserTokens(c *core.CliContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createNewUserToken(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := c.String("username")
|
||||
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
|
||||
return err
|
||||
}
|
||||
|
||||
printTokenInfo(token)
|
||||
fmt.Printf("[NewToken] %s\n", tokenString)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearUserTokens(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
@@ -626,7 +792,11 @@ func exportUserTransaction(c *core.CliContext) error {
|
||||
filePath := c.String("file")
|
||||
fileType := c.String("type")
|
||||
|
||||
if fileType != "" && fileType != "csv" && fileType != "tsv" {
|
||||
if fileType == "" {
|
||||
fileType = "csv"
|
||||
}
|
||||
|
||||
if fileType != "csv" && fileType != "tsv" {
|
||||
log.CliErrorf(c, "[user_data.exportUserTransaction] export file type is not supported")
|
||||
return errs.ErrNotSupported
|
||||
}
|
||||
@@ -735,6 +905,7 @@ func printUserInfo(user *models.User) {
|
||||
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
|
||||
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
|
||||
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
|
||||
fmt.Printf("[FeatureRestriction] %s (%d)\n", user.FeatureRestriction, user.FeatureRestriction)
|
||||
fmt.Printf("[Deleted] %t\n", user.Deleted)
|
||||
fmt.Printf("[EmailVerified] %t\n", user.EmailVerified)
|
||||
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(user.CreatedUnixTime), user.CreatedUnixTime)
|
||||
|
||||
+25
-14
@@ -103,6 +103,8 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.NoRoute(bindApi(api.Default.ApiNotFound))
|
||||
router.NoMethod(bindApi(api.Default.MethodNotAllowed))
|
||||
|
||||
serverSettingsCacheStore := persistence.NewInMemoryStore(time.Minute)
|
||||
|
||||
router.StaticFile("/", filepath.Join(config.StaticRootPath, "index.html"))
|
||||
router.Static("/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
@@ -114,12 +116,14 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.StaticFile("favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
|
||||
router.StaticFile("touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
mobileEntryRoute := router.Group("/mobile")
|
||||
mobileEntryRoute.Use(bindMiddleware(middlewares.ServerSettingsCookie(config)))
|
||||
{
|
||||
mobileEntryRoute.StaticFile("/", filepath.Join(config.StaticRootPath, "mobile.html"))
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
}
|
||||
|
||||
router.StaticFile("/mobile", filepath.Join(config.StaticRootPath, "mobile.html"))
|
||||
router.Static("/mobile/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/mobile/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
router.Static("/mobile/img", filepath.Join(config.StaticRootPath, "img"))
|
||||
@@ -129,16 +133,13 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.StaticFile("/mobile/touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("/mobile/manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("/mobile/sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/mobile/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/mobile/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
}
|
||||
|
||||
desktopEntryRoute := router.Group("/desktop")
|
||||
desktopEntryRoute.Use(bindMiddleware(middlewares.ServerSettingsCookie(config)))
|
||||
{
|
||||
desktopEntryRoute.StaticFile("/", filepath.Join(config.StaticRootPath, "desktop.html"))
|
||||
}
|
||||
router.StaticFile("/desktop", filepath.Join(config.StaticRootPath, "desktop.html"))
|
||||
router.Static("/desktop/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/desktop/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
router.Static("/desktop/img", filepath.Join(config.StaticRootPath, "img"))
|
||||
@@ -148,6 +149,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.StaticFile("/desktop/touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("/desktop/manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("/desktop/sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/desktop/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/desktop/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
@@ -171,11 +173,6 @@ func startWebServer(c *core.CliContext) error {
|
||||
|
||||
router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
|
||||
|
||||
if config.Mode == settings.MODE_DEVELOPMENT {
|
||||
devRoute := router.Group("/dev")
|
||||
devRoute.GET("/cookies", bindMiddleware(middlewares.ServerSettingsCookie(config)))
|
||||
}
|
||||
|
||||
proxyRoute := router.Group("/proxy")
|
||||
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
||||
{
|
||||
@@ -318,6 +315,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
||||
|
||||
if config.EnableDataImport {
|
||||
apiV1Route.POST("/transactions/parse_dsv_file.json", bindApi(api.Transactions.TransactionParseImportDsvFileDataHandler))
|
||||
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
||||
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
||||
}
|
||||
@@ -420,6 +418,19 @@ func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin
|
||||
}
|
||||
}
|
||||
|
||||
func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
|
||||
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
result, _, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/javascript", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, "text/javascript", "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
|
||||
+44
-4
@@ -180,6 +180,12 @@ email_verify_token_expired_time = 3600
|
||||
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
|
||||
password_reset_token_expired_time = 3600
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_ip_per_minute = 5
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_user_per_minute = 5
|
||||
|
||||
# Add X-Request-Id header to response to track user request or error, default is true
|
||||
request_id_header = true
|
||||
|
||||
@@ -217,6 +223,21 @@ avatar_provider = internal
|
||||
# For "internal" avatar provider only, maximum allowed user avatar file size (1 - 4294967295 bytes)
|
||||
max_user_avatar_size = 1048576
|
||||
|
||||
# The default feature restrictions after user registration (feature types separated by commas), leave blank for no restrictions
|
||||
# Supports the following feature types:
|
||||
# 1: Update Password
|
||||
# 2: Update Email
|
||||
# 3: Update Profile Basic Info
|
||||
# 4: Update Avatar
|
||||
# 5: Logout Other Session
|
||||
# 6: Enable Two-Factor Authentication
|
||||
# 7: Disable Enable Two-Factor Authentication
|
||||
# 8: Forget Password
|
||||
# 9: Import Transactions
|
||||
# 10: Export Transactions
|
||||
# 11: Clear All Data
|
||||
default_feature_restrictions =
|
||||
|
||||
[data]
|
||||
# Set to true to allow users to export their data
|
||||
enable_export = true
|
||||
@@ -227,13 +248,22 @@ enable_import = true
|
||||
# Maximum allowed import file size (1 - 4294967295 bytes)
|
||||
max_import_file_size = 10485760
|
||||
|
||||
[tip]
|
||||
# Set to true to display custom tips in login page
|
||||
enable_tips_in_login_page = false
|
||||
|
||||
# The custom tips displayed in login page, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
|
||||
# For example, login_page_tips_content_zh_hans means the notification content in Chinese (Simplified)
|
||||
login_page_tips_content =
|
||||
|
||||
[notification]
|
||||
# Set to true to display custom notification in home page every time users register
|
||||
enable_notification_after_register = false
|
||||
|
||||
# The notification content displayed each time users register, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
|
||||
# For example, after_login_notification_content_zh_hans means the notification content in Simplified Chinese
|
||||
# For example, after_login_notification_content_zh_hans means the notification content in Chinese (Simplified)
|
||||
after_register_notification_content =
|
||||
|
||||
# Set to true to display custom notification in home page every time users login
|
||||
@@ -291,7 +321,7 @@ amap_application_key =
|
||||
# "external_proxy": use an external proxy to request amap api (amap application secret should be set by external proxy)
|
||||
# "plain_text": append amap application secret to frontend request directly (insecurity for public network)
|
||||
# Please visit https://developer.amap.com/api/jsapi-v2/guide/abc/load for more information
|
||||
amap_security_verification_method = plain_text
|
||||
amap_security_verification_method = internal_proxy
|
||||
|
||||
# For "amap" map provider only, Amap JavaScript API application secret, this setting must be provided when "amap_security_verification_method" is set to "internal_proxy" or "plain_text", please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
|
||||
amap_application_secret =
|
||||
@@ -316,11 +346,21 @@ custom_map_tile_server_default_zoom_level = 14
|
||||
|
||||
[exchange_rates]
|
||||
# Exchange rates data source, supports the following types:
|
||||
# "euro_central_bank": https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html
|
||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||
# "reserve_bank_of_australia": https://www.rba.gov.au/statistics/frequency/exchange-rates.html
|
||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
||||
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
|
||||
# "euro_central_bank": https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html
|
||||
# "national_bank_of_georgia": https://nbg.gov.ge/en/monetary-policy/currency
|
||||
# "central_bank_of_hungary": https://www.mnb.hu/en/arfolyamok
|
||||
# "bank_of_israel": https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/
|
||||
# "central_bank_of_myanmar": https://forex.cbm.gov.mm/index.php/fxrate
|
||||
# "norges_bank": https://www.norges-bank.no/en/topics/Statistics/exchange_rates/
|
||||
# "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/
|
||||
# "national_bank_of_romania": https://www.bnr.ro/Exchange-rates-1224.aspx
|
||||
# "bank_of_russia": https://www.cbr.ru/eng/currency_base/daily/
|
||||
# "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates
|
||||
# "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/
|
||||
# "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx
|
||||
data_source = euro_central_bank
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import vueTsEslintConfig from '@vue/eslint-config-typescript';
|
||||
|
||||
export default [
|
||||
...pluginVue.configs['flat/essential'],
|
||||
...vueTsEslintConfig(),
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'**/*.{js,jsx,cjs,mjs}'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'**/*.{vue,ts,tsx,mts,js,jsx,cjs,mjs}'
|
||||
],
|
||||
rules: {
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true
|
||||
}]
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -1,28 +1,29 @@
|
||||
module github.com/mayswind/ezbookkeeping
|
||||
|
||||
go 1.22
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.2
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||
github.com/gin-contrib/cache v1.3.0
|
||||
github.com/gin-contrib/gzip v1.0.1
|
||||
github.com/gin-contrib/cache v1.3.1
|
||||
github.com/gin-contrib/gzip v1.2.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.11.0
|
||||
github.com/go-playground/validator/v10 v10.22.0
|
||||
github.com/go-co-op/gocron/v2 v2.15.0
|
||||
github.com/go-playground/validator/v10 v10.24.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/minio/minio-go/v7 v7.0.74
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/minio/minio-go/v7 v7.0.85
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/urfave/cli/v2 v2.27.3
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v2 v2.27.5
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/text v0.17.0
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.34.0
|
||||
golang.org/x/text v0.22.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
xorm.io/builder v0.3.13
|
||||
@@ -34,30 +35,30 @@ require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.12.7 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
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.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // 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.3 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gomodule/redigo v1.8.9 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -65,22 +66,21 @@ require (
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/arch v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.36.2 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -12,10 +12,11 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
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.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
|
||||
github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.2 h1:jxAJuN9fOot/cyz5Q6dUuMJF5OqQ6+5GfA8FjjQ0R4o=
|
||||
github.com/bytedance/sonic/loader v0.2.2/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
@@ -23,10 +24,9 @@ github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLI
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
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=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -39,38 +39,38 @@ 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.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cache v1.3.0 h1:wEEw38uvb4rTraQJVpd9ex4ZotXNlc0fSaSUsuPXS/w=
|
||||
github.com/gin-contrib/cache v1.3.0/go.mod h1:EA63LrWGI5vwSI95TS5fgBrtxZ1tM2NKx+NrEeyEDcU=
|
||||
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
|
||||
github.com/gin-contrib/gzip v1.0.1/go.mod h1:njt428fdUNRvjuJf16tZMYZ2Yl+WQB53X5wmhDwXvC4=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/cache v1.3.1 h1:EWjkOaLocs5fGt9feQaI7rt1GZbDyatFXEUh2/s3ZI8=
|
||||
github.com/gin-contrib/cache v1.3.1/go.mod h1:6Tme0p3QEF/Ck/KUcq7h/OAqZvUDjHRH1DtQbNgfIX0=
|
||||
github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI=
|
||||
github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
|
||||
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -80,12 +80,12 @@ github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -98,14 +98,14 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.74 h1:fTo/XlPBTSpo3BAMshlwKL5RspXRv9us5UeHEGYCFe0=
|
||||
github.com/minio/minio-go/v7 v7.0.74/go.mod h1:qydcVzV8Hqtj1VtEocfxbmVFa2siu6HGa+LDEPogjD8=
|
||||
github.com/minio/minio-go/v7 v7.0.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo=
|
||||
github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
|
||||
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=
|
||||
@@ -116,8 +116,8 @@ github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
@@ -126,8 +126,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
@@ -142,8 +142,8 @@ 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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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=
|
||||
@@ -152,34 +152,33 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/urfave/cli/v2 v2.27.3 h1:/POWahRmdh7uztQ3CYnaDddk0Rm90PyOgIxgW2rr41M=
|
||||
github.com/urfave/cli/v2 v2.27.3/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
|
||||
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
|
||||
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
Generated
+4043
-2605
File diff suppressed because it is too large
Load Diff
+33
-24
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -15,49 +15,58 @@
|
||||
"serve": "cross-env NODE_ENV=development vite",
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"serve:dist": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
"lint": "tsc --noEmit && eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@vuepic/vue-datepicker": "^9.0.1",
|
||||
"axios": "^1.7.3",
|
||||
"@vuepic/vue-datepicker": "^11.0.1",
|
||||
"axios": "^1.7.9",
|
||||
"cbor-js": "^0.1.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dom7": "^4.0.6",
|
||||
"echarts": "^5.5.1",
|
||||
"framework7": "^8.3.3",
|
||||
"echarts": "^5.6.0",
|
||||
"framework7": "^8.3.4",
|
||||
"framework7-icons": "^5.0.5",
|
||||
"framework7-vue": "^8.3.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"framework7-vue": "^8.3.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"line-awesome": "^1.3.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"pinia": "^2.2.1",
|
||||
"moment-timezone": "^0.5.47",
|
||||
"pinia": "^2.3.1",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"skeleton-elements": "^4.0.1",
|
||||
"swiper": "^10.2.0",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"vue": "^3.4.37",
|
||||
"vue-echarts": "^6.7.3",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.4.3",
|
||||
"ua-parser-js": "^1.0.39",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-i18n": "^11.1.1",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.6.13"
|
||||
"vuetify": "^3.7.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@tsconfig/node22": "^22.0.0",
|
||||
"@types/cbor-js": "^0.1.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/git-rev-sync": "^2.0.2",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"eslint": "^9.20.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"git-rev-sync": "^3.0.2",
|
||||
"postcss-preset-env": "^9.5.16",
|
||||
"sass": "^1.77.6",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-pwa": "^0.20.0",
|
||||
"vite-plugin-vuetify": "^2.0.3"
|
||||
"postcss-preset-env": "^10.1.3",
|
||||
"sass": "^1.84.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vite-plugin-vuetify": "^2.1.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
|
||||
+82
-15
@@ -28,6 +28,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
accounts: services.Accounts,
|
||||
@@ -159,6 +162,11 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrAccountCategoryInvalid
|
||||
}
|
||||
|
||||
if accountCreateReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountCreateReq.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] cannot set statement date with category \"%d\"", accountCreateReq.Category)
|
||||
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
|
||||
}
|
||||
|
||||
if accountCreateReq.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||
if len(accountCreateReq.SubAccounts) > 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot have any sub-accounts")
|
||||
@@ -169,6 +177,11 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot set currency placeholder")
|
||||
return nil, errs.ErrAccountCurrencyInvalid
|
||||
}
|
||||
|
||||
if accountCreateReq.Balance != 0 && accountCreateReq.BalanceTime <= 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account balance time is not set")
|
||||
return nil, errs.ErrAccountBalanceTimeNotSet
|
||||
}
|
||||
} else if accountCreateReq.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
if len(accountCreateReq.SubAccounts) < 1 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account does not have any sub-accounts")
|
||||
@@ -189,19 +202,29 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
subAccount := accountCreateReq.SubAccounts[i]
|
||||
|
||||
if subAccount.Category != accountCreateReq.Category {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] category of sub-account not equals to parent")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] category of sub-account#%d not equals to parent", i)
|
||||
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
|
||||
}
|
||||
|
||||
if subAccount.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account type invalid")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d type invalid", i)
|
||||
return nil, errs.ErrSubAccountTypeInvalid
|
||||
}
|
||||
|
||||
if subAccount.Currency == validators.ParentAccountCurrencyPlaceholder {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account cannot set currency placeholder")
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d cannot set currency placeholder", i)
|
||||
return nil, errs.ErrAccountCurrencyInvalid
|
||||
}
|
||||
|
||||
if subAccount.Balance != 0 && subAccount.BalanceTime <= 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d balance time is not set", i)
|
||||
return nil, errs.ErrAccountBalanceTimeNotSet
|
||||
}
|
||||
|
||||
if subAccount.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d cannot set statement date", i)
|
||||
return nil, errs.ErrCannotSetStatementDateForSubAccount
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warnf(c, "[accounts.AccountCreateHandler] account type invalid, type is %d", accountCreateReq.Type)
|
||||
@@ -216,8 +239,8 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, maxOrderId+1)
|
||||
childrenAccounts := a.createSubAccountModels(uid, &accountCreateReq)
|
||||
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, false, maxOrderId+1)
|
||||
childrenAccounts, childrenAccountBalanceTimes := a.createSubAccountModels(uid, &accountCreateReq)
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId)
|
||||
@@ -255,7 +278,7 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
||||
}
|
||||
}
|
||||
|
||||
err = a.accounts.CreateAccounts(c, mainAccount, childrenAccounts, utcOffset)
|
||||
err = a.accounts.CreateAccounts(c, mainAccount, accountCreateReq.BalanceTime, childrenAccounts, childrenAccountBalanceTimes, utcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
|
||||
@@ -302,8 +325,9 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
|
||||
mainAccount, exists := accountMap[accountModifyReq.Id]
|
||||
|
||||
if _, exists := accountMap[accountModifyReq.Id]; !exists {
|
||||
if !exists {
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
@@ -311,10 +335,26 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrCannotAddOrDeleteSubAccountsWhenModify
|
||||
}
|
||||
|
||||
if accountModifyReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountModifyReq.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] cannot set statement date with category \"%d\"", accountModifyReq.Category)
|
||||
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
|
||||
}
|
||||
|
||||
if mainAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
||||
subAccount := accountModifyReq.SubAccounts[i]
|
||||
|
||||
if subAccount.CreditCardStatementDate != 0 {
|
||||
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set statement date", i)
|
||||
return nil, errs.ErrCannotSetStatementDateForSubAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anythingUpdate := false
|
||||
var toUpdateAccounts []*models.Account
|
||||
|
||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, accountMap[accountModifyReq.Id])
|
||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
|
||||
|
||||
if toUpdateAccount != nil {
|
||||
anythingUpdate = true
|
||||
@@ -328,7 +368,7 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id])
|
||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
|
||||
|
||||
if toUpdateSubAccount != nil {
|
||||
anythingUpdate = true
|
||||
@@ -468,7 +508,13 @@ func (a *AccountsApi) AccountDeleteHandler(c *core.WebContext) (any, *errs.Error
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, order int32) *models.Account {
|
||||
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, isSubAccount bool, order int32) *models.Account {
|
||||
accountExtend := &models.AccountExtend{}
|
||||
|
||||
if !isSubAccount && accountCreateReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
accountExtend.CreditCardStatementDate = &accountCreateReq.CreditCardStatementDate
|
||||
}
|
||||
|
||||
return &models.Account{
|
||||
Uid: uid,
|
||||
Name: accountCreateReq.Name,
|
||||
@@ -480,24 +526,33 @@ func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.
|
||||
Currency: accountCreateReq.Currency,
|
||||
Balance: accountCreateReq.Balance,
|
||||
Comment: accountCreateReq.Comment,
|
||||
Extend: accountExtend,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) []*models.Account {
|
||||
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) ([]*models.Account, []int64) {
|
||||
if len(accountCreateReq.SubAccounts) <= 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
childrenAccounts := make([]*models.Account, len(accountCreateReq.SubAccounts))
|
||||
childrenAccountBalanceTimes := make([]int64, len(accountCreateReq.SubAccounts))
|
||||
|
||||
for i := int32(0); i < int32(len(accountCreateReq.SubAccounts)); i++ {
|
||||
childrenAccounts[i] = a.createNewAccountModel(uid, accountCreateReq.SubAccounts[i], i+1)
|
||||
childrenAccounts[i] = a.createNewAccountModel(uid, accountCreateReq.SubAccounts[i], true, i+1)
|
||||
childrenAccountBalanceTimes[i] = accountCreateReq.SubAccounts[i].BalanceTime
|
||||
}
|
||||
|
||||
return childrenAccounts
|
||||
return childrenAccounts, childrenAccountBalanceTimes
|
||||
}
|
||||
|
||||
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account) *models.Account {
|
||||
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) *models.Account {
|
||||
newAccountExtend := &models.AccountExtend{}
|
||||
|
||||
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
|
||||
}
|
||||
|
||||
newAccount := &models.Account{
|
||||
AccountId: oldAccount.AccountId,
|
||||
Uid: uid,
|
||||
@@ -506,6 +561,7 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
Icon: accountModifyReq.Icon,
|
||||
Color: accountModifyReq.Color,
|
||||
Comment: accountModifyReq.Comment,
|
||||
Extend: newAccountExtend,
|
||||
Hidden: accountModifyReq.Hidden,
|
||||
}
|
||||
|
||||
@@ -518,5 +574,16 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
return newAccount
|
||||
}
|
||||
|
||||
if (newAccount.Extend != nil && oldAccount.Extend == nil) ||
|
||||
(newAccount.Extend == nil && oldAccount.Extend != nil) {
|
||||
return newAccount
|
||||
}
|
||||
|
||||
oldAccountExtend := oldAccount.Extend
|
||||
|
||||
if newAccountExtend.CreditCardStatementDate != oldAccountExtend.CreditCardStatementDate {
|
||||
return newAccount
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
// AuthorizationsApi represents authorization api
|
||||
type AuthorizationsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
ApiWithUserInfo
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
@@ -27,6 +29,12 @@ var (
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
ApiWithUserInfo: ApiWithUserInfo{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
@@ -51,7 +59,23 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
|
||||
return nil, errs.ErrLoginNameOrPasswordInvalid
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
|
||||
err = a.CheckFailureCount(c, 0)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
user, uid, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
|
||||
|
||||
if errs.IsCustomError(err) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
|
||||
@@ -133,6 +157,13 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
@@ -142,6 +173,14 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
|
||||
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
|
||||
|
||||
err = a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrPasscodeInvalid
|
||||
}
|
||||
|
||||
@@ -196,6 +235,13 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
|
||||
|
||||
if err != nil {
|
||||
@@ -226,6 +272,15 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
|
||||
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt)
|
||||
|
||||
if errs.IsCustomError(err) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrTwoFactorRecoveryCodeNotExist)
|
||||
|
||||
@@ -5,9 +5,13 @@ import (
|
||||
"sort"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const internalTransactionPictureUrlFormat = "%spictures/%d.%s"
|
||||
@@ -100,6 +104,7 @@ func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, cl
|
||||
|
||||
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
|
||||
type ApiUsingDuplicateChecker struct {
|
||||
ApiUsingConfig
|
||||
container *duplicatechecker.DuplicateCheckerContainer
|
||||
}
|
||||
|
||||
@@ -113,6 +118,67 @@ func (a *ApiUsingDuplicateChecker) SetSubmissionRemark(checkerType duplicatechec
|
||||
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
|
||||
}
|
||||
|
||||
// CheckFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
|
||||
func (a *ApiUsingDuplicateChecker) CheckFailureCount(c *core.WebContext, uid int64) error {
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
|
||||
clientIp := c.ClientIP()
|
||||
ipFailureCount := a.container.GetFailureCount(clientIp)
|
||||
|
||||
if ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
|
||||
uidFailureCount := a.container.GetFailureCount(utils.Int64ToString(uid))
|
||||
|
||||
if uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndIncreaseFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
|
||||
func (a *ApiUsingDuplicateChecker) CheckAndIncreaseFailureCount(c *core.WebContext, uid int64) error {
|
||||
clientIp := c.ClientIP()
|
||||
ipFailureCount := uint32(0)
|
||||
uidFailureCount := uint32(0)
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
|
||||
ipFailureCount = a.container.GetFailureCount(clientIp)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
|
||||
uidFailureCount = a.container.GetFailureCount(utils.Int64ToString(uid))
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount < a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", previous failure count: %d", clientIp, ipFailureCount)
|
||||
a.container.IncreaseFailureCount(clientIp)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount < a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", previous failure count: %d", uid, uidFailureCount)
|
||||
a.container.IncreaseFailureCount(utils.Int64ToString(uid))
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApiUsingAvatarProvider represents an api that need to use avatar provider
|
||||
type ApiUsingAvatarProvider struct {
|
||||
container *avatars.AvatarProviderContainer
|
||||
|
||||
@@ -147,6 +147,10 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.templates.DeleteAllTemplates(c, uid)
|
||||
|
||||
if err != nil {
|
||||
@@ -204,6 +208,10 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
||||
return nil, "", errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION) {
|
||||
return nil, "", errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -55,12 +55,23 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *
|
||||
Timeout: time.Duration(a.CurrentConfig().ExchangeRatesRequestTimeout) * time.Millisecond,
|
||||
}
|
||||
|
||||
urls := dataSource.GetRequestUrls()
|
||||
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(urls))
|
||||
requests, err := dataSource.BuildRequests()
|
||||
|
||||
for i := 0; i < len(urls); i++ {
|
||||
req, _ := http.NewRequest("GET", urls[i], nil)
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s ", settings.Version))
|
||||
if err != nil {
|
||||
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(requests))
|
||||
|
||||
for i := 0; i < len(requests); i++ {
|
||||
req := requests[i]
|
||||
|
||||
if len(req.Header.Values("User-Agent")) < 1 {
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s", settings.Version))
|
||||
} else if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Del("User-Agent")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
@@ -76,6 +87,9 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
log.Debugf(c, "[exchange_rates.LatestExchangeRateHandler] response#%d is %s", i, body)
|
||||
|
||||
exchangeRateResp, err := dataSource.Parse(c, body)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_ReserveBankOfAustraliaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.ReserveBankOfAustraliaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "AUD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"CAD", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
|
||||
"MYR", "NZD", "PHP", "SGD", "THB", "TWD", "USD", "VND"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfCanadaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "CAD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BRL", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR",
|
||||
"JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PEN", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD",
|
||||
"USD", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CzechNationalBankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CzechNationalBankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "CZK", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN",
|
||||
"BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD",
|
||||
"CAD", "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "DJF", "DKK", "DOP", "DZD",
|
||||
"EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD",
|
||||
"HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD", "JOD", "JPY",
|
||||
"KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD",
|
||||
"MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN",
|
||||
"NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG",
|
||||
"QAR", "RON", "RSD", "RUB", "RWF",
|
||||
"SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL",
|
||||
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "UYU", "UZS",
|
||||
"VND", "VUV", "WST", "XAF", "XCD", "XOF", "XPF", "YER", "ZAR", "ZMW"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_DanmarksNationalbankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.DanmarksNationalbankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "DKK", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "EUR", "GBP", "HKD", "HUF",
|
||||
"IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PLN", "RON", "SEK", "SGD",
|
||||
"THB", "TRY", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_EuroCentralBankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.EuroCentralBankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "EUR", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "GBP", "HKD", "HUF",
|
||||
"IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PLN", "RON", "SEK", "SGD",
|
||||
"THB", "TRY", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfGeorgiaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfGeorgiaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "GEL", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BGN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK",
|
||||
"DKK", "EGP", "EUR", "GBP", "HKD", "HUF", "ILS", "INR", "IRR", "ISK", "JPY", "KGS", "KRW", "KWD", "KZT",
|
||||
"MDL", "NOK", "NZD", "PLN", "QAR", "RON", "RSD", "RUB", "SEK", "SGD", "TJS", "TMT", "TRY",
|
||||
"UAH", "USD", "UZS", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfHungaryDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfHungaryDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "HUF", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "EUR",
|
||||
"GBP", "HKD", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD",
|
||||
"PHP", "PLN", "RON", "RSD", "RUB", "SEK", "SGD", "THB", "TRY", "UAH", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfIsraelDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfIsraelDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "ILS", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "CAD", "CHF", "DKK", "EGP", "EUR", "GBP",
|
||||
"JOD", "JPY", "LBP", "NOK", "SEK", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfMyanmarDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfMyanmarDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "MMK", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BDT", "BND", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK",
|
||||
"EGP", "EUR", "GBP", "HKD", "IDR", "ILS", "INR", "JPY", "KES", "KHR", "KRW", "KWD", "LAK", "LKR",
|
||||
"MYR", "NOK", "NPR", "NZD", "PHP", "PKR", "RSD", "RUB", "SAR", "SEK", "SGD", "THB", "USD", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NorgesBankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NorgesBankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "NOK", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BDT", "BGN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK", "DKK",
|
||||
"EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MMK", "MXN", "MYR", "NZD",
|
||||
"PHP", "PKR", "PLN", "RON", "RUB", "SEK", "SGD", "THB", "TRY", "TWD", "USD", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfPolandDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfPolandDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "PLN", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN",
|
||||
"BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BND", "BOB", "BRL", "BSD", "BWP", "BYN", "BZD",
|
||||
"CAD", "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "CZK",
|
||||
"DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD",
|
||||
"GBP", "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD",
|
||||
"HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK",
|
||||
"JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KRW", "KWD", "KZT",
|
||||
"LAK", "LBP", "LKR", "LRD", "LSL", "LYD",
|
||||
"MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN",
|
||||
"NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PYG",
|
||||
"QAR", "RON", "RSD", "RUB", "RWF",
|
||||
"SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL",
|
||||
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "UYU", "UZS",
|
||||
"VES", "VND", "VUV", "WST", "XAF", "XCD", "XOF", "XPF", "YER", "ZAR", "ZMW", "ZWG"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfRomaniaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfRomaniaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "RON", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "EGP",
|
||||
"EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MDL", "MXN", "MYR",
|
||||
"NOK", "NZD", "PHP", "PLN", "RSD", "RUB", "SEK", "SGD", "THB", "TRY", "UAH", "USD", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfRussiaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfRussiaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "RUB", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BGN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK",
|
||||
"DKK", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "INR", "JPY", "KGS", "KRW", "KZT", "MDL",
|
||||
"NOK", "NZD", "PLN", "QAR", "RON", "RSD", "SEK", "SGD", "THB", "TJS", "TMT", "TRY",
|
||||
"UAH", "USD", "UZS", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_SwissNationalBankDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.SwissNationalBankDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "CHF", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"EUR", "GBP", "JPY", "USD"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfUzbekistanDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfUzbekistanDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "UZS", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AFN", "AMD", "ARS", "AUD", "AZN",
|
||||
"BDT", "BGN", "BHD", "BND", "BRL", "BYN", "CAD", "CHF", "CNY", "CUP", "CZK",
|
||||
"DKK", "DZD", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK",
|
||||
"JOD", "JPY", "KGS", "KHR", "KRW", "KWD", "KZT", "LAK", "LBP", "LYD",
|
||||
"MAD", "MDL", "MMK", "MNT", "MXN", "MYR", "NOK", "NZD", "OMR", "PHP", "PKR", "PLN",
|
||||
"QAR", "RON", "RSD", "RUB", "SAR", "SDG", "SEK", "SGD", "SYP",
|
||||
"THB", "TJS", "TMT", "TND", "TRY", "UAH", "USD", "UYU", "VES", "VND", "YER", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_InternationalMonetaryFundDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.InternationalMonetaryFundDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "USD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AUD", "BND", "BRL", "BWP", "CAD", "CHF", "CLP", "CNY", "CZK",
|
||||
"DKK", "DZD", "EUR", "GBP", "ILS", "INR", "JPY", "KRW", "KWD", "MUR", "MXN", "MYR", "NOK", "NZD",
|
||||
"OMR", "PEN", "PHP", "PLN", "QAR", "RUB", "SAR", "SEK", "SGD", "THB", "TTD", "UYU", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *models.LatestExchangeRateResponse {
|
||||
config := &settings.Config{
|
||||
ExchangeRatesDataSource: dataSourceType,
|
||||
ExchangeRatesRequestTimeout: 10000,
|
||||
ExchangeRatesProxy: "system",
|
||||
ExchangeRatesSkipTLSVerify: true,
|
||||
}
|
||||
|
||||
settingsContainer := &settings.ConfigContainer{
|
||||
Current: config,
|
||||
}
|
||||
|
||||
err := exchangerates.InitializeExchangeRatesDataSource(config)
|
||||
assert.Nil(t, err)
|
||||
|
||||
exchangeRatesApi := &ExchangeRatesApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settingsContainer,
|
||||
},
|
||||
}
|
||||
|
||||
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
response, err := exchangeRatesApi.LatestExchangeRateHandler(&core.WebContext{
|
||||
Context: ginContext,
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
|
||||
exchangeRateResponse := response.(*models.LatestExchangeRateResponse)
|
||||
assert.NotNil(t, exchangeRateResponse)
|
||||
|
||||
return exchangeRateResponse
|
||||
}
|
||||
|
||||
func checkExchangeRatesHaveSpecifiedCurrencies(t *testing.T, baseCurrency string, currencyCodes []string, exchangeRates []*models.LatestExchangeRate) {
|
||||
assert.Equal(t, len(currencyCodes)+1, len(exchangeRates))
|
||||
|
||||
currencyCodesInExchangeRates := make(map[string]*models.LatestExchangeRate, len(exchangeRates))
|
||||
|
||||
for i := 0; i < len(exchangeRates); i++ {
|
||||
exchangeRate := exchangeRates[i]
|
||||
currencyCodesInExchangeRates[exchangeRate.Currency] = exchangeRate
|
||||
}
|
||||
|
||||
allCurrencyCodes := append(currencyCodes, baseCurrency)
|
||||
|
||||
for i := 0; i < len(allCurrencyCodes); i++ {
|
||||
exchangeRate, has := currencyCodesInExchangeRates[allCurrencyCodes[i]]
|
||||
assert.True(t, has, allCurrencyCodes[i])
|
||||
|
||||
if has {
|
||||
rate, err := utils.StringToFloat64(exchangeRate.Rate)
|
||||
assert.Nil(t, err)
|
||||
assert.Greater(t, rate, float64(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if a.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
@@ -109,6 +113,10 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any,
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if a.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const ezbookkeepingServerSettingsGlobalVariableName = "EZBOOKKEEPING_SERVER_SETTINGS"
|
||||
const ezbookkeepingServerSettingsGlobalVariableFullName = "window." + ezbookkeepingServerSettingsGlobalVariableName
|
||||
const ezbookkeepingServerSettingsJavascriptFileHeader = ezbookkeepingServerSettingsGlobalVariableFullName +
|
||||
"=" + ezbookkeepingServerSettingsGlobalVariableFullName + "||{};\n"
|
||||
|
||||
// ServerSettingsApi represents server settings api
|
||||
type ServerSettingsApi struct {
|
||||
ApiUsingConfig
|
||||
}
|
||||
|
||||
// Initialize a server settings api singleton instance
|
||||
var (
|
||||
ServerSettings = &ServerSettingsApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// ServerSettingsJavascriptHandler returns the javascript contains server settings
|
||||
func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
|
||||
config := a.CurrentConfig()
|
||||
builder := &strings.Builder{}
|
||||
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
|
||||
|
||||
a.appendBooleanSetting(builder, "r", config.EnableUserRegister)
|
||||
a.appendBooleanSetting(builder, "f", config.EnableUserForgetPassword)
|
||||
a.appendBooleanSetting(builder, "v", config.EnableUserVerifyEmail)
|
||||
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
|
||||
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
|
||||
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
|
||||
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
|
||||
|
||||
if config.LoginPageTips.Enabled {
|
||||
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
|
||||
}
|
||||
|
||||
a.appendStringSetting(builder, "m", config.MapProvider)
|
||||
|
||||
if config.EnableMapDataFetchProxy &&
|
||||
(config.MapProvider == settings.OpenStreetMapProvider ||
|
||||
config.MapProvider == settings.OpenStreetMapHumanitarianStyleProvider ||
|
||||
config.MapProvider == settings.OpenTopoMapProvider ||
|
||||
config.MapProvider == settings.OPNVKarteMapProvider ||
|
||||
config.MapProvider == settings.CyclOSMMapProvider ||
|
||||
config.MapProvider == settings.CartoDBMapProvider ||
|
||||
config.MapProvider == settings.TomTomMapProvider ||
|
||||
config.MapProvider == settings.TianDiTuProvider ||
|
||||
config.MapProvider == settings.CustomProvider) {
|
||||
a.appendBooleanSetting(builder, "mp", config.EnableMapDataFetchProxy)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.CustomProvider {
|
||||
a.appendStringSetting(builder, "cmzl", fmt.Sprintf("%d-%d-%d", config.CustomMapTileServerMinZoomLevel, config.CustomMapTileServerMaxZoomLevel, config.CustomMapTileServerDefaultZoomLevel))
|
||||
|
||||
if !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "cmsu", config.CustomMapTileServerTileLayerUrl)
|
||||
|
||||
if config.CustomMapTileServerAnnotationLayerUrl != "" {
|
||||
a.appendStringSetting(builder, "cmau", config.CustomMapTileServerAnnotationLayerUrl)
|
||||
}
|
||||
} else {
|
||||
if config.CustomMapTileServerAnnotationLayerUrl != "" {
|
||||
a.appendBooleanSetting(builder, "cmap", config.EnableMapDataFetchProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TomTomMapProvider && config.TomTomMapAPIKey != "" && !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "tmak", config.TomTomMapAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.TianDiTuProvider && config.TianDiTuAPIKey != "" && !config.EnableMapDataFetchProxy {
|
||||
a.appendStringSetting(builder, "tdak", config.TianDiTuAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.GoogleMapProvider && config.GoogleMapAPIKey != "" {
|
||||
a.appendStringSetting(builder, "gmak", config.GoogleMapAPIKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.BaiduMapProvider && config.BaiduMapAK != "" {
|
||||
a.appendStringSetting(builder, "bmak", config.BaiduMapAK)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapApplicationKey != "" {
|
||||
a.appendStringSetting(builder, "amak", config.AmapApplicationKey)
|
||||
}
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod != "" {
|
||||
a.appendStringSetting(builder, "amsv", config.AmapSecurityVerificationMethod)
|
||||
|
||||
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationExternalProxyMethod {
|
||||
a.appendStringSetting(builder, "amep", config.AmapApiExternalProxyUrl)
|
||||
}
|
||||
|
||||
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationPlainTextMethod {
|
||||
a.appendStringSetting(builder, "amas", config.AmapApplicationSecret)
|
||||
}
|
||||
}
|
||||
|
||||
if config.ExchangeRatesRequestTimeoutExceedDefaultValue {
|
||||
a.appendIntegerSetting(builder, "errt", int(config.ExchangeRatesRequestTimeout))
|
||||
}
|
||||
|
||||
return []byte(builder.String()), "", nil
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key string, value string) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
|
||||
a.appendEncodedString(builder, value)
|
||||
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.TipConfig) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]={\n")
|
||||
|
||||
builder.WriteString("'default'")
|
||||
builder.WriteRune(':')
|
||||
a.appendEncodedString(builder, value.DefaultContent)
|
||||
|
||||
for languageTag, content := range value.MultiLanguageContent {
|
||||
builder.WriteString(",\n")
|
||||
a.appendEncodedString(builder, languageTag)
|
||||
builder.WriteRune(':')
|
||||
a.appendEncodedString(builder, content)
|
||||
}
|
||||
|
||||
builder.WriteString("\n};\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendBooleanSetting(builder *strings.Builder, key string, value bool) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
|
||||
if value {
|
||||
builder.WriteRune('1')
|
||||
} else {
|
||||
builder.WriteRune('0')
|
||||
}
|
||||
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendIntegerSetting(builder *strings.Builder, key string, value int) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
builder.WriteString("]=")
|
||||
builder.WriteString(utils.IntToString(value))
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendEncodedString(builder *strings.Builder, content string) {
|
||||
builder.WriteRune('\'')
|
||||
runes := []rune(content)
|
||||
|
||||
for i := 0; i < len(runes); i++ {
|
||||
switch runes[i] {
|
||||
case '\\':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('\\')
|
||||
case '\'':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('\'')
|
||||
case '\n':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('n')
|
||||
case '\r':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('r')
|
||||
case '\t':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('t')
|
||||
case '\f':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('f')
|
||||
case '\b':
|
||||
builder.WriteRune('\\')
|
||||
builder.WriteRune('b')
|
||||
default:
|
||||
builder.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteRune('\'')
|
||||
}
|
||||
@@ -135,6 +135,22 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
return nil, errs.ErrInvalidTokenId
|
||||
}
|
||||
|
||||
if utils.Int64ToString(tokenRecord.UserTokenId) != c.GetTokenClaims().UserTokenId || tokenRecord.CreatedUnixTime != c.GetTokenClaims().IssuedAt {
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[token.TokenRevokeHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteToken(c, tokenRecord)
|
||||
|
||||
if err != nil {
|
||||
@@ -170,6 +186,24 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error)
|
||||
|
||||
tokens = append(tokens[:currentTokenIndex], tokens[currentTokenIndex+1:]...)
|
||||
|
||||
if len(tokens) < 1 {
|
||||
return nil, errs.ErrTokenRecordNotFound
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteTokens(c, uid, tokens)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -29,6 +29,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
categories: services.TransactionCategories,
|
||||
|
||||
@@ -26,6 +26,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
|
||||
@@ -31,6 +31,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
templates: services.TransactionTemplates,
|
||||
@@ -156,7 +159,12 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any
|
||||
}
|
||||
|
||||
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
|
||||
template := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
|
||||
template, err := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create new template for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
|
||||
@@ -260,6 +268,34 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
|
||||
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
|
||||
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
|
||||
|
||||
if templateModifyReq.ScheduledStartDate != nil {
|
||||
startTime, err := utils.ParseFromLongDateFirstTime(*templateModifyReq.ScheduledStartDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled start date for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
startUnixTime := startTime.Unix()
|
||||
newTemplate.ScheduledStartTime = &startUnixTime
|
||||
}
|
||||
|
||||
if templateModifyReq.ScheduledEndDate != nil {
|
||||
endTime, err := utils.ParseFromLongDateLastTime(*templateModifyReq.ScheduledEndDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled end date for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
endUnixTime := endTime.Unix()
|
||||
newTemplate.ScheduledEndTime = &endUnixTime
|
||||
}
|
||||
|
||||
if newTemplate.ScheduledStartTime != nil && newTemplate.ScheduledEndTime != nil && *newTemplate.ScheduledStartTime > *newTemplate.ScheduledEndTime {
|
||||
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
|
||||
}
|
||||
}
|
||||
|
||||
if newTemplate.Name == template.Name &&
|
||||
@@ -277,6 +313,8 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
|
||||
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
|
||||
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
|
||||
newTemplate.ScheduledStartTime == template.ScheduledStartTime &&
|
||||
newTemplate.ScheduledEndTime == template.ScheduledEndTime &&
|
||||
newTemplate.ScheduledAt == template.ScheduledAt &&
|
||||
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
@@ -419,7 +457,7 @@ func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) *models.TransactionTemplate {
|
||||
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) (*models.TransactionTemplate, error) {
|
||||
template := &models.TransactionTemplate{
|
||||
Uid: uid,
|
||||
TemplateType: templateCreateReq.TemplateType,
|
||||
@@ -441,9 +479,35 @@ func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCrea
|
||||
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
|
||||
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
|
||||
|
||||
if templateCreateReq.ScheduledStartDate != nil {
|
||||
startTime, err := utils.ParseFromLongDateFirstTime(*templateCreateReq.ScheduledStartDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startUnixTime := startTime.Unix()
|
||||
template.ScheduledStartTime = &startUnixTime
|
||||
}
|
||||
|
||||
if templateCreateReq.ScheduledEndDate != nil {
|
||||
endTime, err := utils.ParseFromLongDateLastTime(*templateCreateReq.ScheduledEndDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endUnixTime := endTime.Unix()
|
||||
template.ScheduledEndTime = &endUnixTime
|
||||
}
|
||||
|
||||
if template.ScheduledStartTime != nil && template.ScheduledEndTime != nil && *template.ScheduledStartTime > *template.ScheduledEndTime {
|
||||
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
|
||||
}
|
||||
}
|
||||
|
||||
return template
|
||||
return template, nil
|
||||
}
|
||||
|
||||
func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
|
||||
|
||||
+202
-9
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -8,6 +9,8 @@ import (
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters"
|
||||
baseconverters "github.com/mayswind/ezbookkeeping/pkg/converters/base"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -40,6 +43,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
transactions: services.Transactions,
|
||||
@@ -89,7 +95,7 @@ func (a *TransactionsApi) TransactionCountHandler(c *core.WebContext) (any, *err
|
||||
}
|
||||
}
|
||||
|
||||
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionCountReq.AmountFilter, transactionCountReq.Keyword)
|
||||
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionCountReq.TagFilterType, transactionCountReq.AmountFilter, transactionCountReq.Keyword)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionCountHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -160,7 +166,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
|
||||
var totalCount int64
|
||||
|
||||
if transactionListReq.WithCount {
|
||||
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -168,7 +174,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
|
||||
}
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsByMaxTime(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.Page, transactionListReq.Count, true, true)
|
||||
transactions, err := a.transactions.GetTransactionsByMaxTime(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.Page, transactionListReq.Count, true, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transactions earlier than \"%d\" for user \"uid:%d\", because %s", transactionListReq.MaxTime, uid, err.Error())
|
||||
@@ -260,7 +266,7 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
|
||||
}
|
||||
}
|
||||
|
||||
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to get transactions in month \"%d-%d\" for user \"uid:%d\", because %s", transactionListReq.Year, transactionListReq.Month, uid, err.Error())
|
||||
@@ -299,8 +305,20 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any,
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
var allTagIds []int64
|
||||
noTags := statisticReq.TagIds == "none"
|
||||
|
||||
if !noTags {
|
||||
allTagIds, err = a.getTagIds(statisticReq.TagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionStatisticsHandler] get transaction tag ids error, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, utcOffset, statisticReq.UseTransactionTimezone)
|
||||
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, allTagIds, noTags, statisticReq.TagFilterType, utcOffset, statisticReq.UseTransactionTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionStatisticsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -350,18 +368,30 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
var allTagIds []int64
|
||||
noTags := statisticTrendsReq.TagIds == "none"
|
||||
|
||||
if !noTags {
|
||||
allTagIds, err = a.getTagIds(statisticTrendsReq.TagIds)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionStatisticsTrendsHandler] get transaction tag ids error, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, utcOffset, statisticTrendsReq.UseTransactionTimezone)
|
||||
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, allTagIds, noTags, statisticTrendsReq.TagFilterType, utcOffset, statisticTrendsReq.UseTransactionTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionStatisticsTrendsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
statisticTrendsResp := make(models.TransactionStatisticTrendsItemSlice, 0, len(allMonthlyTotalAmounts))
|
||||
statisticTrendsResp := make(models.TransactionStatisticTrendsResponseItemSlice, 0, len(allMonthlyTotalAmounts))
|
||||
|
||||
for yearMonth, monthlyTotalAmounts := range allMonthlyTotalAmounts {
|
||||
monthlyStatisticResp := &models.TransactionStatisticTrendsItem{
|
||||
monthlyStatisticResp := &models.TransactionStatisticTrendsResponseItem{
|
||||
Year: yearMonth / 100,
|
||||
Month: yearMonth % 100,
|
||||
Items: make([]*models.TransactionStatisticResponseItem, len(monthlyTotalAmounts)),
|
||||
@@ -1006,6 +1036,83 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionParseImportDsvFileDataHandler returns the parsed file data by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrParameterInvalid
|
||||
}
|
||||
|
||||
fileTypes := form.Value["fileType"]
|
||||
|
||||
if len(fileTypes) < 1 || fileTypes[0] == "" {
|
||||
return nil, errs.ErrImportFileTypeIsEmpty
|
||||
}
|
||||
|
||||
fileType := fileTypes[0]
|
||||
|
||||
if !converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
|
||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||
}
|
||||
|
||||
fileEncodings := form.Value["fileEncoding"]
|
||||
|
||||
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
}
|
||||
|
||||
fileEncoding := fileEncodings[0]
|
||||
dataParser, err := converters.CreateNewDelimiterSeparatedValuesDataParser(fileType, fileEncoding)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||
}
|
||||
|
||||
importFiles := form.File["file"]
|
||||
|
||||
if len(importFiles) < 1 {
|
||||
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] there is no import file in request for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrNoFilesUpload
|
||||
}
|
||||
|
||||
if importFiles[0].Size < 1 {
|
||||
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the size of import file in request is zero for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrUploadedFileEmpty
|
||||
}
|
||||
|
||||
if importFiles[0].Size > int64(a.CurrentConfig().MaxImportFileSize) {
|
||||
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of import file for user \"uid:%d\"", importFiles[0].Size, a.CurrentConfig().MaxImportFileSize, uid)
|
||||
return nil, errs.ErrExceedMaxUploadFileSize
|
||||
}
|
||||
|
||||
importFile, err := importFiles[0].Open()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
defer importFile.Close()
|
||||
fileData, err := io.ReadAll(importFile)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allLines, err := dataParser.ParseDsvFileLines(c, fileData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to parse import file data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return allLines, nil
|
||||
}
|
||||
|
||||
// TransactionParseImportFileHandler returns the parsed transaction data by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
@@ -1030,7 +1137,84 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
}
|
||||
|
||||
fileType := fileTypes[0]
|
||||
dataImporter, err := converters.GetTransactionDataImporter(fileType)
|
||||
|
||||
var dataImporter baseconverters.TransactionDataImporter
|
||||
|
||||
if converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
|
||||
fileEncodings := form.Value["fileEncoding"]
|
||||
|
||||
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
}
|
||||
|
||||
fileEncoding := fileEncodings[0]
|
||||
|
||||
columnMappings := form.Value["columnMapping"]
|
||||
|
||||
if len(columnMappings) < 1 || columnMappings[0] == "" {
|
||||
return nil, errs.ErrImportFileColumnMappingInvalid
|
||||
}
|
||||
|
||||
var columnIndexMapping = map[datatable.TransactionDataTableColumn]int{}
|
||||
err = json.Unmarshal([]byte(columnMappings[0]), &columnIndexMapping)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse column mapping for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrImportFileColumnMappingInvalid
|
||||
}
|
||||
|
||||
transactionTypeMappings := form.Value["transactionTypeMapping"]
|
||||
|
||||
if len(transactionTypeMappings) < 1 || transactionTypeMappings[0] == "" {
|
||||
return nil, errs.ErrImportFileTransactionTypeMappingInvalid
|
||||
}
|
||||
|
||||
var transactionTypeNameMapping = map[string]models.TransactionType{}
|
||||
err = json.Unmarshal([]byte(transactionTypeMappings[0]), &transactionTypeNameMapping)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse transaction type mapping for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrImportFileTransactionTypeMappingInvalid
|
||||
}
|
||||
|
||||
hasHeaderLines := form.Value["hasHeaderLine"]
|
||||
hasHeaderLine := false
|
||||
|
||||
if len(hasHeaderLines) > 0 {
|
||||
hasHeaderLine = hasHeaderLines[0] == "true"
|
||||
}
|
||||
|
||||
timeFormats := form.Value["timeFormat"]
|
||||
|
||||
if len(timeFormats) < 1 || timeFormats[0] == "" {
|
||||
return nil, errs.ErrImportFileTransactionTimeFormatInvalid
|
||||
}
|
||||
|
||||
timezoneFormats := form.Value["timezoneFormat"]
|
||||
timezoneFormat := ""
|
||||
|
||||
if len(timezoneFormats) > 0 {
|
||||
timezoneFormat = timezoneFormats[0]
|
||||
}
|
||||
|
||||
geoLocationSeparators := form.Value["geoSeparator"]
|
||||
geoLocationSeparator := ""
|
||||
|
||||
if len(geoLocationSeparators) > 0 {
|
||||
geoLocationSeparator = geoLocationSeparators[0]
|
||||
}
|
||||
|
||||
transactionTagSeparators := form.Value["tagSeparator"]
|
||||
transactionTagSeparator := ""
|
||||
|
||||
if len(transactionTagSeparators) > 0 {
|
||||
transactionTagSeparator = transactionTagSeparators[0]
|
||||
}
|
||||
|
||||
dataImporter, err = converters.CreateNewDelimiterSeparatedValuesDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, geoLocationSeparator, transactionTagSeparator)
|
||||
} else {
|
||||
dataImporter, err = converters.GetTransactionDataImporter(fileType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||
@@ -1060,6 +1244,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
defer importFile.Close()
|
||||
fileData, err := io.ReadAll(importFile)
|
||||
|
||||
if err != nil {
|
||||
@@ -1077,6 +1262,10 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(c, user.Uid)
|
||||
|
||||
if err != nil {
|
||||
@@ -1201,6 +1390,10 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
newTransactions := make([]*models.Transaction, len(transactionImportReq.Transactions))
|
||||
|
||||
for i := 0; i < len(transactionImportReq.Transactions); i++ {
|
||||
|
||||
@@ -81,6 +81,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.WebCo
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user)
|
||||
|
||||
if err != nil {
|
||||
@@ -141,6 +145,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.WebCo
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
twoFactorSetting := &models.TwoFactor{
|
||||
Uid: uid,
|
||||
Secret: confirmReq.Secret,
|
||||
@@ -229,6 +237,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.WebContext)
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(disableReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
DefaultCurrency: userRegisterReq.DefaultCurrency,
|
||||
FirstDayOfWeek: userRegisterReq.FirstDayOfWeek,
|
||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
err = a.users.CreateUser(c, user)
|
||||
@@ -251,6 +252,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
userUpdateReq.Email = strings.TrimSpace(userUpdateReq.Email)
|
||||
userUpdateReq.Nickname = strings.TrimSpace(userUpdateReq.Nickname)
|
||||
|
||||
modifyProfileBasicInfo := false
|
||||
anythingUpdate := false
|
||||
userNew := &models.User{
|
||||
Uid: user.Uid,
|
||||
@@ -258,12 +260,20 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
}
|
||||
|
||||
if userUpdateReq.Email != "" && userUpdateReq.Email != user.Email {
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
user.Email = userUpdateReq.Email
|
||||
userNew.Email = userUpdateReq.Email
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.Password != "" {
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
@@ -277,6 +287,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.Nickname != "" && userUpdateReq.Nickname != user.Nickname {
|
||||
user.Nickname = userUpdateReq.Nickname
|
||||
userNew.Nickname = userUpdateReq.Nickname
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
@@ -299,12 +310,14 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
|
||||
user.DefaultAccountId = userUpdateReq.DefaultAccountId
|
||||
userNew.DefaultAccountId = userUpdateReq.DefaultAccountId
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
|
||||
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.TransactionEditScope = models.TRANSACTION_EDIT_SCOPE_INVALID
|
||||
@@ -316,18 +329,21 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
user.Language = userUpdateReq.Language
|
||||
userNew.Language = userUpdateReq.Language
|
||||
modifyUserLanguage = true
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.DefaultCurrency != "" && userUpdateReq.DefaultCurrency != user.DefaultCurrency {
|
||||
user.DefaultCurrency = userUpdateReq.DefaultCurrency
|
||||
userNew.DefaultCurrency = userUpdateReq.DefaultCurrency
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.FirstDayOfWeek != nil && *userUpdateReq.FirstDayOfWeek != user.FirstDayOfWeek {
|
||||
user.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
|
||||
userNew.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
|
||||
@@ -336,6 +352,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
|
||||
user.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.LongDateFormat = core.LONG_DATE_FORMAT_INVALID
|
||||
@@ -344,6 +361,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.ShortDateFormat != nil && *userUpdateReq.ShortDateFormat != user.ShortDateFormat {
|
||||
user.ShortDateFormat = *userUpdateReq.ShortDateFormat
|
||||
userNew.ShortDateFormat = *userUpdateReq.ShortDateFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ShortDateFormat = core.SHORT_DATE_FORMAT_INVALID
|
||||
@@ -352,6 +370,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.LongTimeFormat != nil && *userUpdateReq.LongTimeFormat != user.LongTimeFormat {
|
||||
user.LongTimeFormat = *userUpdateReq.LongTimeFormat
|
||||
userNew.LongTimeFormat = *userUpdateReq.LongTimeFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.LongTimeFormat = core.LONG_TIME_FORMAT_INVALID
|
||||
@@ -360,6 +379,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.ShortTimeFormat != nil && *userUpdateReq.ShortTimeFormat != user.ShortTimeFormat {
|
||||
user.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
|
||||
userNew.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
|
||||
@@ -368,6 +388,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
|
||||
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DecimalSeparator = core.DECIMAL_SEPARATOR_INVALID
|
||||
@@ -376,6 +397,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.DigitGroupingSymbol != nil && *userUpdateReq.DigitGroupingSymbol != user.DigitGroupingSymbol {
|
||||
user.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
|
||||
userNew.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DigitGroupingSymbol = core.DIGIT_GROUPING_SYMBOL_INVALID
|
||||
@@ -384,6 +406,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.DigitGrouping != nil && *userUpdateReq.DigitGrouping != user.DigitGrouping {
|
||||
user.DigitGrouping = *userUpdateReq.DigitGrouping
|
||||
userNew.DigitGrouping = *userUpdateReq.DigitGrouping
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
|
||||
@@ -392,6 +415,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
||||
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
||||
@@ -400,6 +424,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
|
||||
user.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
|
||||
userNew.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.ExpenseAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
|
||||
@@ -408,11 +433,16 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
if userUpdateReq.IncomeAmountColor != nil && *userUpdateReq.IncomeAmountColor != user.IncomeAmountColor {
|
||||
user.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
|
||||
userNew.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.IncomeAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
|
||||
}
|
||||
|
||||
if modifyProfileBasicInfo && user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if modifyUserLanguage || userNew.DecimalSeparator != core.DECIMAL_SEPARATOR_INVALID || userNew.DigitGroupingSymbol != core.DIGIT_GROUPING_SYMBOL_INVALID {
|
||||
decimalSeparator := userNew.DecimalSeparator
|
||||
digitGroupingSymbol := userNew.DigitGroupingSymbol
|
||||
@@ -525,6 +555,10 @@ func (a *UsersApi) UserUpdateAvatarHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
@@ -588,6 +622,10 @@ func (a *UsersApi) UserRemoveAvatarHandler(c *core.WebContext) (any, *errs.Error
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if user.CustomAvatarType == "" {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ func (l *UserDataCli) AddNewUser(c *core.CliContext, username string, email stri
|
||||
DefaultCurrency: defaultCurrency,
|
||||
FirstDayOfWeek: core.WEEKDAY_SUNDAY,
|
||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||
FeatureRestriction: l.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
err := l.users.CreateUser(c, user)
|
||||
@@ -237,6 +238,57 @@ func (l *UserDataCli) DisableUser(c *core.CliContext, username string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserFeatureRestrictions sets user feature restrictions according to the specified user name
|
||||
func (l *UserDataCli) SetUserFeatureRestrictions(c *core.CliContext, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.SetUserFeatureRestrictions] user name is empty")
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
err := l.users.UpdateUserFeatureRestriction(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.SetUserFeatureRestrictions] failed to set user feature restrictions by user name \"%s\", because %s", username, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddUserFeatureRestrictions adds user feature restrictions according to the specified user name
|
||||
func (l *UserDataCli) AddUserFeatureRestrictions(c *core.CliContext, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.AddUserFeatureRestrictions] user name is empty")
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
err := l.users.AddUserFeatureRestriction(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.AddUserFeatureRestrictions] failed to add user feature restrictions by user name \"%s\", because %s", username, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserFeatureRestrictions removes user feature restrictions according to the specified user name
|
||||
func (l *UserDataCli) RemoveUserFeatureRestrictions(c *core.CliContext, username string, featureRestriction core.UserFeatureRestrictions) error {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.RemoveUserFeatureRestrictions] user name is empty")
|
||||
return errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
err := l.users.RemoveUserFeatureRestriction(c, username, featureRestriction)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.RemoveUserFeatureRestrictions] failed to remove user feature restrictions by user name \"%s\", because %s", username, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResendVerifyEmail resends an email with account activation link
|
||||
func (l *UserDataCli) ResendVerifyEmail(c *core.CliContext, username string) error {
|
||||
if !l.CurrentConfig().EnableUserVerifyEmail {
|
||||
@@ -352,6 +404,30 @@ func (l *UserDataCli) ListUserTokens(c *core.CliContext, username string) ([]*mo
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// CreateNewUserToken returns a new token for the specified user
|
||||
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string) (*models.TokenRecord, string, error) {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] user name is empty")
|
||||
return nil, "", errs.ErrUsernameIsEmpty
|
||||
}
|
||||
|
||||
user, err := l.GetUserByUsername(c, username)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] error occurs when getting user by user name")
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
token, tokenRecord, err := l.tokens.CreateTokenViaCli(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] failed to create token for user \"%s\", because %s", username, err.Error())
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return tokenRecord, token, nil
|
||||
}
|
||||
|
||||
// ClearUserTokens clears all tokens of the specified user
|
||||
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
|
||||
if username == "" {
|
||||
|
||||
@@ -418,7 +418,7 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
|
||||
geoLongitude := float64(0)
|
||||
geoLatitude := float64(0)
|
||||
|
||||
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) {
|
||||
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) && c.geoLocationSeparator != "" {
|
||||
geoLocationItems := strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), c.geoLocationSeparator)
|
||||
|
||||
if len(geoLocationItems) == 2 {
|
||||
@@ -442,7 +442,13 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
|
||||
var tagNames []string
|
||||
|
||||
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_TAGS) {
|
||||
tagNameItems := strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
|
||||
var tagNameItems []string
|
||||
|
||||
if c.transactionTagSeparator != "" {
|
||||
tagNameItems = strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
|
||||
} else {
|
||||
tagNameItems = append(tagNameItems, dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS))
|
||||
}
|
||||
|
||||
for i := 0; i < len(tagNameItems); i++ {
|
||||
tagName := tagNameItems[i]
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package dsv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
"golang.org/x/text/encoding/japanese"
|
||||
"golang.org/x/text/encoding/korean"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/encoding/traditionalchinese"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/base"
|
||||
csvconverter "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"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"
|
||||
)
|
||||
|
||||
var supportedFileTypeSeparators = map[string]rune{
|
||||
"custom_csv": ',',
|
||||
"custom_tsv": '\t',
|
||||
}
|
||||
|
||||
var supportedFileEncodings = map[string]encoding.Encoding{
|
||||
"utf-8": unicode.UTF8, // UTF-8
|
||||
"utf-8-bom": unicode.UTF8BOM, // UTF-8 with BOM
|
||||
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), // UTF-16 Little Endian
|
||||
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM), // UTF-16 Big Endian
|
||||
"cp437": charmap.CodePage437, // OEM United States (CP-437)
|
||||
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
|
||||
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
|
||||
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
|
||||
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
|
||||
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
|
||||
"cp850": charmap.CodePage850, // Western European (CP-850)
|
||||
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
|
||||
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
|
||||
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
|
||||
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
|
||||
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
|
||||
"cp865": charmap.CodePage865, // North European (CP-865)
|
||||
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
|
||||
"cp852": charmap.CodePage852, // Central European (CP-852)
|
||||
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
|
||||
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
|
||||
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
|
||||
"cp860": charmap.CodePage860, // Portuguese (CP-860)
|
||||
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
|
||||
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
|
||||
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
|
||||
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
|
||||
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
|
||||
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
|
||||
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
|
||||
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
|
||||
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
|
||||
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
|
||||
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
|
||||
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
|
||||
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
|
||||
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
|
||||
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
|
||||
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
|
||||
"cp862": charmap.CodePage862, // Hebrew (CP-862)
|
||||
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
|
||||
"windows-874": charmap.Windows874, // Thai (Windows-874)
|
||||
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
|
||||
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
|
||||
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
|
||||
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
|
||||
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
|
||||
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
|
||||
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
|
||||
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
|
||||
}
|
||||
|
||||
var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_MODIFY_BALANCE: utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)),
|
||||
models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)),
|
||||
models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)),
|
||||
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
||||
}
|
||||
|
||||
type CustomTransactionDataDsvFileParser interface {
|
||||
ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error)
|
||||
}
|
||||
|
||||
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
|
||||
type customTransactionDataDsvFileImporter struct {
|
||||
fileEncoding encoding.Encoding
|
||||
separator rune
|
||||
columnIndexMapping map[datatable.TransactionDataTableColumn]int
|
||||
transactionTypeNameMapping map[string]models.TransactionType
|
||||
hasHeaderLine bool
|
||||
timeFormat string
|
||||
timezoneFormat string
|
||||
geoLocationSeparator string
|
||||
transactionTagSeparator string
|
||||
}
|
||||
|
||||
// ParseDsvFileLines returns the parsed file lines for specified the dsv file data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.Comma = c.separator
|
||||
csvReader.FieldsPerRecord = -1
|
||||
|
||||
allLines := make([][]string, 0)
|
||||
|
||||
for {
|
||||
items, err := csvReader.Read()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDsvFileLines] cannot parse dsv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
if len(items) == 1 && items[0] == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for index := range items {
|
||||
items[index] = strings.Trim(items[index], " ")
|
||||
}
|
||||
|
||||
allLines = append(allLines, items)
|
||||
}
|
||||
|
||||
return allLines, nil
|
||||
}
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
allLines, err := c.ParseDsvFileLines(ctx, data)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if !c.hasHeaderLine {
|
||||
allLines = append([][]string{{}}, allLines...)
|
||||
}
|
||||
|
||||
dataTable := csvconverter.CreateNewCustomCsvImportedDataTable(allLines)
|
||||
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat)
|
||||
dataTableImporter := datatable.CreateNewImporter(customTransactionTypeNameMapping, c.geoLocationSeparator, c.transactionTagSeparator)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
// IsDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
|
||||
func IsDelimiterSeparatedValuesFileType(fileType string) bool {
|
||||
_, exists := supportedFileTypeSeparators[fileType]
|
||||
return exists
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataDsvFileParser returns a new custom dsv parser for transaction data
|
||||
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataDsvFileParser, error) {
|
||||
separator, exists := supportedFileTypeSeparators[fileType]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
enc, exists := supportedFileEncodings[fileEncoding]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileEncodingNotSupported
|
||||
}
|
||||
|
||||
return &customTransactionDataDsvFileImporter{
|
||||
fileEncoding: enc,
|
||||
separator: separator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataDsvFileImporter returns a new custom dsv importer for transaction data
|
||||
func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, geoLocationSeparator string, transactionTagSeparator string) (base.TransactionDataImporter, error) {
|
||||
separator, exists := supportedFileTypeSeparators[fileType]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
enc, exists := supportedFileEncodings[fileEncoding]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileEncodingNotSupported
|
||||
}
|
||||
|
||||
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_AMOUNT]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
return &customTransactionDataDsvFileImporter{
|
||||
fileEncoding: enc,
|
||||
separator: separator,
|
||||
columnIndexMapping: columnIndexMapping,
|
||||
transactionTypeNameMapping: transactionTypeNameMapping,
|
||||
hasHeaderLine: hasHeaderLine,
|
||||
timeFormat: timeFormat,
|
||||
timezoneFormat: timezoneFormat,
|
||||
geoLocationSeparator: geoLocationSeparator,
|
||||
transactionTagSeparator: transactionTagSeparator,
|
||||
}, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
package dsv
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"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"
|
||||
)
|
||||
|
||||
// customPlainTextDataTable defines the structure of custom plain text transaction data table
|
||||
type customPlainTextDataTable struct {
|
||||
innerDataTable datatable.ImportedDataTable
|
||||
columnIndexMapping map[datatable.TransactionDataTableColumn]int
|
||||
transactionTypeNameMapping map[string]models.TransactionType
|
||||
timeFormat string
|
||||
timezoneFormat string
|
||||
timeFormatIncludeTimezone bool
|
||||
}
|
||||
|
||||
// customPlainTextDataRow defines the structure of custom plain text transaction data row
|
||||
type customPlainTextDataRow struct {
|
||||
transactionDataTable *customPlainTextDataTable
|
||||
rowData map[datatable.TransactionDataTableColumn]string
|
||||
isValid bool
|
||||
}
|
||||
|
||||
// customPlainTextDataRowIterator defines the structure of custom plain text transaction data row iterator
|
||||
type customPlainTextDataRowIterator struct {
|
||||
transactionDataTable *customPlainTextDataTable
|
||||
innerIterator datatable.ImportedDataRowIterator
|
||||
}
|
||||
|
||||
// HasColumn returns whether the data table has specified column
|
||||
func (t *customPlainTextDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
|
||||
// custom dsv file allows no sub category, account name and related account name column mapping, but data table converter needs these columns
|
||||
if column == datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY ||
|
||||
column == datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME ||
|
||||
column == datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME {
|
||||
return true
|
||||
}
|
||||
|
||||
// timezone column will be added when original time format contains timezone
|
||||
if t.timeFormatIncludeTimezone && column == datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE {
|
||||
return true
|
||||
}
|
||||
|
||||
_, exists := t.columnIndexMapping[column]
|
||||
return exists
|
||||
}
|
||||
|
||||
// TransactionRowCount returns the total count of transaction data row
|
||||
func (t *customPlainTextDataTable) TransactionRowCount() int {
|
||||
return t.innerDataTable.DataRowCount()
|
||||
}
|
||||
|
||||
// TransactionRowIterator returns the iterator of transaction data row
|
||||
func (t *customPlainTextDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
|
||||
return &customPlainTextDataRowIterator{
|
||||
transactionDataTable: t,
|
||||
innerIterator: t.innerDataTable.DataRowIterator(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid returns whether this row is valid data for importing
|
||||
func (r *customPlainTextDataRow) IsValid() bool {
|
||||
return r.isValid
|
||||
}
|
||||
|
||||
// GetData returns the data in the specified column type
|
||||
func (r *customPlainTextDataRow) GetData(column datatable.TransactionDataTableColumn) string {
|
||||
return r.rowData[column]
|
||||
}
|
||||
|
||||
// HasNext returns whether the iterator does not reach the end
|
||||
func (t *customPlainTextDataRowIterator) HasNext() bool {
|
||||
return t.innerIterator.HasNext()
|
||||
}
|
||||
|
||||
// Next returns the next transaction data row
|
||||
func (t *customPlainTextDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
|
||||
importedRow := t.innerIterator.Next()
|
||||
|
||||
if importedRow == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rowData, isValid, err := t.parseTransaction(ctx, user, importedRow)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.Next] cannot parsing transaction in row \"%s\", because %s", t.innerIterator.CurrentRowId(), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &customPlainTextDataRow{
|
||||
transactionDataTable: t.transactionDataTable,
|
||||
rowData: rowData,
|
||||
isValid: isValid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *customPlainTextDataRowIterator) parseTransaction(ctx core.Context, user *models.User, row datatable.ImportedDataRow) (map[datatable.TransactionDataTableColumn]string, bool, error) {
|
||||
rowData := make(map[datatable.TransactionDataTableColumn]string, len(t.transactionDataTable.columnIndexMapping))
|
||||
|
||||
for column, columnIndex := range t.transactionDataTable.columnIndexMapping {
|
||||
if columnIndex < 0 || columnIndex >= row.ColumnCount() {
|
||||
continue
|
||||
}
|
||||
|
||||
value := row.GetData(columnIndex)
|
||||
rowData[column] = value
|
||||
}
|
||||
|
||||
// parse transaction type
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] != "" {
|
||||
transactionType, exists := t.transactionDataTable.transactionTypeNameMapping[rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]]
|
||||
|
||||
if !exists {
|
||||
log.Warnf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] skip parsing this transaction, because transaction type \"%s\" mapping not defined", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE])
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
mappedTransactionType, exists := customTransactionTypeNameMapping[transactionType]
|
||||
|
||||
if !exists {
|
||||
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction type \"%s\", because type \"%d\" is invalid", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE], transactionType)
|
||||
return nil, false, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = mappedTransactionType
|
||||
}
|
||||
|
||||
// parse date time
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
|
||||
dateTime, err := time.Parse(t.transactionDataTable.timeFormat, rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
|
||||
|
||||
if err != nil {
|
||||
return nil, false, errs.ErrTransactionTimeInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
|
||||
|
||||
if t.transactionDataTable.timeFormatIncludeTimezone {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
|
||||
}
|
||||
}
|
||||
|
||||
// parse timezone
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] != "" {
|
||||
if t.transactionDataTable.timezoneFormat == "Z" || t.transactionDataTable.timezoneFormat == "" { // -HH:mm
|
||||
// Do Nothing
|
||||
} else if t.transactionDataTable.timezoneFormat == "ZZ" { // -HHmm
|
||||
timezone := rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE]
|
||||
|
||||
if len(timezone) != 5 {
|
||||
return nil, false, errs.ErrTransactionTimeZoneInvalid
|
||||
}
|
||||
|
||||
timezone = timezone[:3] + ":" + timezone[3:]
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = timezone
|
||||
} else {
|
||||
return nil, false, errs.ErrImportFileTransactionTimezoneFormatInvalid
|
||||
}
|
||||
}
|
||||
|
||||
// use primary category if sub category is empty
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" && rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY] != "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY]
|
||||
}
|
||||
|
||||
// trim trailing zero in decimal
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT], err.Error())
|
||||
return nil, false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
||||
}
|
||||
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
|
||||
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction related amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT], err.Error())
|
||||
return nil, false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amount)
|
||||
}
|
||||
|
||||
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY]; !exists {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
||||
}
|
||||
|
||||
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]; !exists {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
}
|
||||
|
||||
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]; !exists {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
}
|
||||
|
||||
return rowData, true, nil
|
||||
}
|
||||
|
||||
// CreateNewCustomPlainTextDataTable returns transaction data table from imported data table
|
||||
func CreateNewCustomPlainTextDataTable(dataTable datatable.ImportedDataTable, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, timeFormat string, timezoneFormat string) *customPlainTextDataTable {
|
||||
timeFormatIncludeTimezone := strings.Contains(timeFormat, "z") || strings.Contains(timeFormat, "Z")
|
||||
|
||||
return &customPlainTextDataTable{
|
||||
innerDataTable: dataTable,
|
||||
columnIndexMapping: columnIndexMapping,
|
||||
transactionTypeNameMapping: transactionTypeNameMapping,
|
||||
timeFormat: getDateTimeFormat(timeFormat),
|
||||
timezoneFormat: timezoneFormat,
|
||||
timeFormatIncludeTimezone: timeFormatIncludeTimezone,
|
||||
}
|
||||
}
|
||||
|
||||
func getDateTimeFormat(format string) string {
|
||||
// convert moment.js format to Go format
|
||||
|
||||
format = strings.ReplaceAll(format, "YYYY", "2006")
|
||||
format = strings.ReplaceAll(format, "YY", "06")
|
||||
|
||||
format = strings.ReplaceAll(format, "MMMM", "January")
|
||||
format = strings.ReplaceAll(format, "MMM", "Jan")
|
||||
format = strings.ReplaceAll(format, "MM", "01")
|
||||
format = strings.ReplaceAll(format, "M", "1")
|
||||
|
||||
format = strings.ReplaceAll(format, "DD", "02")
|
||||
format = strings.ReplaceAll(format, "D", "2")
|
||||
|
||||
format = strings.ReplaceAll(format, "dddd", "Monday")
|
||||
format = strings.ReplaceAll(format, "ddd", "Mon")
|
||||
|
||||
format = strings.ReplaceAll(format, "HH", "15")
|
||||
format = strings.ReplaceAll(format, "H", "15")
|
||||
|
||||
format = strings.ReplaceAll(format, "hh", "03")
|
||||
format = strings.ReplaceAll(format, "h", "3")
|
||||
|
||||
format = strings.ReplaceAll(format, "mm", "04")
|
||||
format = strings.ReplaceAll(format, "m", "4")
|
||||
|
||||
format = strings.ReplaceAll(format, "ss", "05")
|
||||
format = strings.ReplaceAll(format, "s", "5")
|
||||
|
||||
for i := 9; i >= 1; i-- {
|
||||
format = strings.ReplaceAll(format, "."+strings.Repeat("S", i), "."+strings.Repeat("9", i))
|
||||
}
|
||||
|
||||
format = strings.ReplaceAll(format, "A", "PM")
|
||||
format = strings.ReplaceAll(format, "a", "pm")
|
||||
|
||||
format = strings.ReplaceAll(format, "zz", "MST")
|
||||
format = strings.ReplaceAll(format, "z", "MST")
|
||||
|
||||
if strings.Contains(format, "ZZ") {
|
||||
format = strings.ReplaceAll(format, "ZZ", "Z0700")
|
||||
} else if strings.Contains(format, "Z") {
|
||||
format = strings.ReplaceAll(format, "Z", "Z07:00")
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
+1
@@ -98,6 +98,7 @@ func (t *gnucashTransactionDataRowIterator) Next(ctx core.Context, user *models.
|
||||
rowItems, isValid, err := t.parseTransaction(ctx, user, data)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[gnucash_transaction_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -129,11 +129,26 @@ func (r *iifDataReader) read(ctx core.Context) ([]*iifAccountDataset, []*iifTran
|
||||
}
|
||||
} else if lastLineSign == iifTransactionLineSignColumnName || lastLineSign == iifTransactionSplitLineSignColumnName {
|
||||
if items[0] == iifTransactionSplitLineSignColumnName {
|
||||
if currentTransactionData == nil {
|
||||
log.Errorf(ctx, "[iif_data_reader.read] expected current transaction data is not nil, but read \"%s\"", items[0])
|
||||
return nil, nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
currentTransactionData.splitData = append(currentTransactionData.splitData, &iifTransactionSplitData{
|
||||
dataItems: items,
|
||||
})
|
||||
lastLineSign = items[0]
|
||||
} else if items[0] == iifTransactionEndLineSignColumnName {
|
||||
if currentTransactionData == nil {
|
||||
log.Errorf(ctx, "[iif_data_reader.read] expected current transaction data is not nil, but read \"%s\"", items[0])
|
||||
return nil, nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
if len(currentTransactionData.splitData) < 1 {
|
||||
log.Errorf(ctx, "[iif_data_reader.read] expected reading transaction split line, but read \"%s\"", items[0])
|
||||
return nil, nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
currentTransactionDataset.transactions = append(currentTransactionDataset.transactions, currentTransactionData)
|
||||
lastLineSign = ""
|
||||
} else {
|
||||
@@ -214,7 +229,7 @@ func (r *iifDataReader) readTransactionSampleLines(ctx core.Context, items []str
|
||||
}
|
||||
|
||||
if len(transactionEndSampleItems) < 1 || transactionEndSampleItems[0] != iifTransactionEndSampleLineSignColumnName {
|
||||
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction end sample line, but read \"%s\"", strings.Join(splitSampleItems, "\t"))
|
||||
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction end sample line, but read \"%s\"", strings.Join(transactionEndSampleItems, "\t"))
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ func TestIIFTransactionDataFileParseImportedData_ParseYearMonthDayFormatTime(t *
|
||||
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayFormatTime(t *testing.T) {
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayYearFormatTime(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -364,6 +364,37 @@ func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayFormatTime(t
|
||||
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayTwoDigitsYearFormatTime(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t9/01/24\tTest Account\t123.45\n"+
|
||||
"SPL\t9/01/24\tTest Account2\t-123.45\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/2/24\tTest Account\t123.45\n"+
|
||||
"SPL\t09/2/24\tTest Account2\t-123.45\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t9/3/24\tTest Account\t123.45\n"+
|
||||
"SPL\t9/3/24\tTest Account2\t-123.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 3, len(allNewTransactions))
|
||||
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
|
||||
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
@@ -377,8 +408,8 @@ func TestIIFTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T)
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t9/1/24\tTest Account\t123.45\n"+
|
||||
"SPL\t9/1/24\tTest Account2\t-123.45\n"+
|
||||
"TRNS\t09-01-2024\tTest Account\t123.45\n"+
|
||||
"SPL\t09-01-2024\tTest Account2\t-123.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||
|
||||
@@ -486,7 +517,7 @@ func TestIIFTransactionDataFileParseImportedData_ParseDescription(t *testing.T)
|
||||
assert.Equal(t, "Test", allNewTransactions[0].Comment)
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) {
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseSplitTransaction(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -495,11 +526,218 @@ func TestIIFTransactionDataFileParseImportedData_NotSupportedToParseSplitTransac
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!ACCNT\tNAME\tACCNTTYPE\n"+
|
||||
"ACCNT\tTest Category\tINC\n"+
|
||||
"ACCNT\tTest Category2\tEXP\n"+
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t123.45\n"+
|
||||
"SPL\t09/01/2024\tTest Category\t-23.45\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/02/2024\tTest Account\t-100.00\n"+
|
||||
"SPL\t09/02/2024\tTest Category2\t30.00\n"+
|
||||
"SPL\t09/02/2024\tTest Account3\t20.00\n"+
|
||||
"SPL\t09/02/2024\tTest Account4\t50.00\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/03/2024\tTest Account\t100.00\n"+
|
||||
"SPL\t09/03/2024\tTest Account2\t-100.00\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/04/2024\tTest Category\t-100.00\n"+
|
||||
"SPL\t09/04/2024\tTest Account\t40.00\n"+
|
||||
"SPL\t09/04/2024\tTest Account2\t60.00\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/05/2024\tTest Category2\t100.00\n"+
|
||||
"SPL\t09/05/2024\tTest Account3\t-40.00\n"+
|
||||
"SPL\t09/05/2024\tTest Account4\t-60.00\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 10, len(allNewTransactions))
|
||||
assert.Equal(t, 4, len(allNewAccounts))
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||
assert.Equal(t, int64(2345), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category", allNewTransactions[0].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
|
||||
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
|
||||
assert.Equal(t, int64(10000), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
|
||||
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||
assert.Equal(t, int64(3000), allNewTransactions[2].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category2", allNewTransactions[2].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
|
||||
assert.Equal(t, int64(2000), allNewTransactions[3].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[3].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
|
||||
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
|
||||
assert.Equal(t, int64(5000), allNewTransactions[4].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[4].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account4", allNewTransactions[4].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[4].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
|
||||
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime))
|
||||
assert.Equal(t, int64(10000), allNewTransactions[5].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[5].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[5].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[6].Type)
|
||||
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[6].TransactionTime))
|
||||
assert.Equal(t, int64(4000), allNewTransactions[6].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category", allNewTransactions[6].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[7].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[7].Type)
|
||||
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[7].TransactionTime))
|
||||
assert.Equal(t, int64(6000), allNewTransactions[7].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[7].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category", allNewTransactions[7].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[8].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[8].Type)
|
||||
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[8].TransactionTime))
|
||||
assert.Equal(t, int64(4000), allNewTransactions[8].Amount)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[8].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category2", allNewTransactions[8].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[9].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[9].Type)
|
||||
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[9].TransactionTime))
|
||||
assert.Equal(t, int64(6000), allNewTransactions[9].Amount)
|
||||
assert.Equal(t, "Test Account4", allNewTransactions[9].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category2", allNewTransactions[9].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
||||
assert.Equal(t, "Test Account3", allNewAccounts[2].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[3].Uid)
|
||||
assert.Equal(t, "Test Account4", allNewAccounts[3].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[3].Currency)
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseSplitTransactionDescription(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
|
||||
"!SPL\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
|
||||
"!ENDTRNS\t\t\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t\"Test\"\t123.45\t\"foo bar\t#test\"\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t\t-100.00\t\"foo\ttest#bar\"\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\t\t-23.45\t\n"+
|
||||
"ENDTRNS\t\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 2, len(allNewTransactions))
|
||||
assert.Equal(t, "foo\ttest#bar", allNewTransactions[0].Comment)
|
||||
assert.Equal(t, "foo bar\t#test", allNewTransactions[1].Comment)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
|
||||
"!SPL\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
|
||||
"!ENDTRNS\t\t\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\tTest\t123.45\t\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t\t-100.00\t\"test\"\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\tfoo\t-12.34\t\n"+
|
||||
"SPL\t09/01/2024\tTest Account4\t\t-11.11\t\n"+
|
||||
"ENDTRNS\t\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 3, len(allNewTransactions))
|
||||
assert.Equal(t, "test", allNewTransactions[0].Comment)
|
||||
assert.Equal(t, "foo", allNewTransactions[1].Comment)
|
||||
assert.Equal(t, "Test", allNewTransactions[2].Comment)
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_NotSupportedSplitTransaction(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// Opening balance transaction
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\t\n"+
|
||||
"TRNS\tBEGINBALCHECK\t09/01/2024\tTest Account\t123.45\n"+
|
||||
"SPL\tBEGINBALCHECK\t09/01/2024\tTest Account2\t-100.00\n"+
|
||||
"SPL\tBEGINBALCHECK\t09/01/2024\tTest Account3\t-23.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotSupportedSplitTransactions.Message)
|
||||
|
||||
// Transaction with invalid amount
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t123 45\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||
|
||||
// Transaction split data with invalid amount
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t123.45\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t-100 00\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||
|
||||
// Transaction amount not equal to sum of split data amount
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t123.00\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
@@ -515,7 +753,7 @@ func TestIIFTransactionDataFileParseImportedData_InvalidDataLines(t *testing.T)
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// Missing Transaction Line
|
||||
//Missing Transaction Line
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
@@ -524,6 +762,14 @@ func TestIIFTransactionDataFileParseImportedData_InvalidDataLines(t *testing.T)
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
|
||||
|
||||
// Missing Transaction And Split Line
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
|
||||
|
||||
// Missing Split Line
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
|
||||
@@ -3,6 +3,7 @@ package iif
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
@@ -59,6 +60,7 @@ type iifTransactionDataRowIterator struct {
|
||||
dataTable *iifTransactionDataTable
|
||||
currentDatasetIndex int
|
||||
currentIndexInDataset int
|
||||
currentSplitDataIndex int
|
||||
}
|
||||
|
||||
// HasColumn returns whether the transaction data table has specified column
|
||||
@@ -72,8 +74,15 @@ func (t *iifTransactionDataTable) TransactionRowCount() int {
|
||||
totalDataRowCount := 0
|
||||
|
||||
for i := 0; i < len(t.transactionDatasets); i++ {
|
||||
transactions := t.transactionDatasets[i]
|
||||
totalDataRowCount += len(transactions.transactions)
|
||||
datasets := t.transactionDatasets[i]
|
||||
|
||||
for j := 0; j < len(datasets.transactions); j++ {
|
||||
transaction := datasets.transactions[j]
|
||||
|
||||
if transaction.splitData != nil {
|
||||
totalDataRowCount += len(transaction.splitData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalDataRowCount
|
||||
@@ -84,7 +93,8 @@ func (t *iifTransactionDataTable) TransactionRowIterator() datatable.Transaction
|
||||
return &iifTransactionDataRowIterator{
|
||||
dataTable: t,
|
||||
currentDatasetIndex: 0,
|
||||
currentIndexInDataset: -1,
|
||||
currentIndexInDataset: 0,
|
||||
currentSplitDataIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +126,9 @@ func (t *iifTransactionDataRowIterator) HasNext() bool {
|
||||
|
||||
if t.currentIndexInDataset+1 < len(currentDataset.transactions) {
|
||||
return true
|
||||
} else if t.currentIndexInDataset < len(currentDataset.transactions) &&
|
||||
t.currentSplitDataIndex+1 < len(currentDataset.transactions[t.currentIndexInDataset].splitData) {
|
||||
return true
|
||||
}
|
||||
|
||||
for i := t.currentDatasetIndex + 1; i < len(allDatasets); i++ {
|
||||
@@ -134,20 +147,29 @@ func (t *iifTransactionDataRowIterator) HasNext() bool {
|
||||
// Next returns the next imported data row
|
||||
func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
|
||||
allDatasets := t.dataTable.transactionDatasets
|
||||
currentIndexInDataset := t.currentIndexInDataset
|
||||
|
||||
for i := t.currentDatasetIndex; i < len(allDatasets); i++ {
|
||||
foundNextRow := false
|
||||
dataset := allDatasets[i]
|
||||
|
||||
if currentIndexInDataset+1 < len(dataset.transactions) {
|
||||
for j := t.currentIndexInDataset; j < len(dataset.transactions); j++ {
|
||||
if t.currentSplitDataIndex+1 < len(dataset.transactions[j].splitData) {
|
||||
t.currentSplitDataIndex++
|
||||
foundNextRow = true
|
||||
break
|
||||
}
|
||||
|
||||
t.currentIndexInDataset++
|
||||
currentIndexInDataset = t.currentIndexInDataset
|
||||
t.currentSplitDataIndex = -1
|
||||
}
|
||||
|
||||
if foundNextRow {
|
||||
break
|
||||
}
|
||||
|
||||
t.currentDatasetIndex++
|
||||
t.currentIndexInDataset = -1
|
||||
currentIndexInDataset = -1
|
||||
t.currentIndexInDataset = 0
|
||||
t.currentSplitDataIndex = -1
|
||||
}
|
||||
|
||||
if t.currentDatasetIndex >= len(allDatasets) {
|
||||
@@ -161,9 +183,28 @@ func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
|
||||
}
|
||||
|
||||
data := currentDataset.transactions[t.currentIndexInDataset]
|
||||
rowItems, err := t.parseTransaction(ctx, user, currentDataset, data)
|
||||
|
||||
if len(data.splitData) < 1 {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.Next] cannot parsing transaction in row#%d (dataset#%d), because split data is empty", t.currentIndexInDataset, t.currentDatasetIndex)
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
if t.currentSplitDataIndex >= len(data.splitData) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(data.splitData) > 1 {
|
||||
_, err := t.isSplitTransactionSupported(ctx, currentDataset, data)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rowItems, err := t.parseTransaction(ctx, user, currentDataset, data, t.currentSplitDataIndex)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.Next] cannot parsing transaction in row#%d-split#%d (dataset#%d), because %s", t.currentIndexInDataset, t.currentSplitDataIndex, t.currentDatasetIndex, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -173,13 +214,7 @@ func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, dataset *iifTransactionDataset, transactionData *iifTransactionData) (map[datatable.TransactionDataTableColumn]string, error) {
|
||||
if len(transactionData.splitData) < 1 {
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
} else if len(transactionData.splitData) > 1 {
|
||||
return nil, errs.ErrNotSupportedSplitTransactions
|
||||
}
|
||||
|
||||
func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, dataset *iifTransactionDataset, transactionData *iifTransactionData, splitDataIndex int) (map[datatable.TransactionDataTableColumn]string, error) {
|
||||
var err error
|
||||
|
||||
data := make(map[datatable.TransactionDataTableColumn]string, len(iifTransactionSupportedColumns))
|
||||
@@ -189,18 +224,18 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactionType, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionTypeColumnName)
|
||||
accountName1, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAccountNameColumnName)
|
||||
accountName2, _ := dataset.getSplitDataItemValue(transactionData.splitData[0], iifTransactionAccountNameColumnName)
|
||||
amount1, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
|
||||
amount2, _ := dataset.getSplitDataItemValue(transactionData.splitData[0], iifTransactionAmountColumnName)
|
||||
amountNum1, err := utils.ParseAmount(strings.ReplaceAll(amount1, ",", ""))
|
||||
transactionType, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionTypeColumnName)
|
||||
mainAccountName, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAccountNameColumnName)
|
||||
splitAccountName, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionAccountNameColumnName)
|
||||
mainAmount, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
|
||||
splitAmount, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionAmountColumnName)
|
||||
mainAmountNum, err := parseAmount(mainAmount)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
amountNum2, err := utils.ParseAmount(strings.ReplaceAll(amount2, ",", ""))
|
||||
splitAmountNum, err := parseAmount(splitAmount)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.ErrAmountInvalid
|
||||
@@ -208,24 +243,35 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
|
||||
if transactionType == iifTransactionTypeBeginningBalance { // balance modification
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName1
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum1)
|
||||
} else if t.dataTable.incomeAccountNames[accountName1] || t.dataTable.incomeAccountNames[accountName2] { // income
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = mainAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(mainAmountNum)
|
||||
} else if (t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[splitAccountName]) ||
|
||||
(t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[mainAccountName]) { // income
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||
categoryName := ""
|
||||
accountName := ""
|
||||
amountNum := int64(0)
|
||||
|
||||
if t.dataTable.incomeAccountNames[accountName1] && !t.dataTable.incomeAccountNames[accountName2] {
|
||||
categoryName = accountName1
|
||||
accountName = accountName2
|
||||
amountNum = amountNum2
|
||||
} else if t.dataTable.incomeAccountNames[accountName2] && !t.dataTable.incomeAccountNames[accountName1] {
|
||||
categoryName = accountName2
|
||||
accountName = accountName1
|
||||
amountNum = amountNum1
|
||||
if t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[splitAccountName] {
|
||||
categoryName = mainAccountName
|
||||
accountName = splitAccountName
|
||||
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = splitAmountNum
|
||||
} else {
|
||||
amountNum = -mainAmountNum
|
||||
}
|
||||
} else if t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[mainAccountName] {
|
||||
categoryName = splitAccountName
|
||||
accountName = mainAccountName
|
||||
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = -splitAmountNum
|
||||
} else {
|
||||
amountNum = mainAmountNum
|
||||
}
|
||||
} else {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all income account", accountName1, accountName2)
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because main account \"%s\" and split account \"%s\" are all income account", mainAccountName, splitAccountName)
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
@@ -240,22 +286,33 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
|
||||
} else if t.dataTable.expenseAccountNames[accountName1] || t.dataTable.expenseAccountNames[accountName2] { // expense
|
||||
} else if (t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[splitAccountName]) ||
|
||||
(t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[mainAccountName]) { // expense
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||
categoryName := ""
|
||||
accountName := ""
|
||||
amountNum := int64(0)
|
||||
|
||||
if t.dataTable.expenseAccountNames[accountName1] && !t.dataTable.expenseAccountNames[accountName2] {
|
||||
categoryName = accountName1
|
||||
accountName = accountName2
|
||||
amountNum = amountNum2
|
||||
} else if t.dataTable.expenseAccountNames[accountName2] && !t.dataTable.expenseAccountNames[accountName1] {
|
||||
categoryName = accountName2
|
||||
accountName = accountName1
|
||||
amountNum = amountNum1
|
||||
if t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[splitAccountName] {
|
||||
categoryName = mainAccountName
|
||||
accountName = splitAccountName
|
||||
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = -splitAmountNum
|
||||
} else {
|
||||
amountNum = mainAmountNum
|
||||
}
|
||||
} else if t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[mainAccountName] {
|
||||
categoryName = splitAccountName
|
||||
accountName = mainAccountName
|
||||
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = splitAmountNum
|
||||
} else {
|
||||
amountNum = -mainAmountNum
|
||||
}
|
||||
} else {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all expense account", accountName1, accountName2)
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because main account \"%s\" and split account \"%s\" are all expense account", mainAccountName, splitAccountName)
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
@@ -269,26 +326,57 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
}
|
||||
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
||||
amountNum := int64(0)
|
||||
relatedAmountNum := int64(0)
|
||||
mainAccountTransferToSplitAccount := false
|
||||
|
||||
if amountNum1 >= 0 {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName2
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum2)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = accountName1
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amountNum1)
|
||||
} else if amountNum2 >= 0 {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName1
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum1)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = accountName2
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amountNum2)
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = splitAmountNum
|
||||
relatedAmountNum = splitAmountNum
|
||||
mainAccountTransferToSplitAccount = amountNum >= 0
|
||||
} else {
|
||||
if mainAmountNum >= 0 {
|
||||
amountNum = splitAmountNum
|
||||
relatedAmountNum = mainAmountNum
|
||||
mainAccountTransferToSplitAccount = false
|
||||
} else if splitAmountNum >= 0 {
|
||||
amountNum = mainAmountNum
|
||||
relatedAmountNum = splitAmountNum
|
||||
mainAccountTransferToSplitAccount = true
|
||||
}
|
||||
}
|
||||
|
||||
if mainAccountTransferToSplitAccount {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = mainAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = splitAccountName
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = splitAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = mainAccountName
|
||||
}
|
||||
|
||||
if amountNum >= 0 {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum)
|
||||
}
|
||||
|
||||
if relatedAmountNum >= 0 {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(relatedAmountNum)
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-relatedAmountNum)
|
||||
}
|
||||
}
|
||||
|
||||
if memo, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionMemoColumnName); memo != "" {
|
||||
if splitMemo, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionMemoColumnName); splitMemo != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = splitMemo
|
||||
} else if memo, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionMemoColumnName); memo != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = memo
|
||||
} else if splitName, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionNameColumnName); splitName != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = splitName
|
||||
} else if name, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionNameColumnName); name != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = name
|
||||
} else {
|
||||
@@ -298,6 +386,49 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (t *iifTransactionDataRowIterator) isSplitTransactionSupported(ctx core.Context, dataset *iifTransactionDataset, transactionData *iifTransactionData) (bool, error) {
|
||||
supportSplitTransactions := true
|
||||
transactionType, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionTypeColumnName)
|
||||
|
||||
if transactionType == iifTransactionTypeBeginningBalance { // balance modification
|
||||
supportSplitTransactions = false
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parse split balance modification transaction#%d (dataset#%d)", t.currentIndexInDataset, t.currentDatasetIndex)
|
||||
} else {
|
||||
transactionAmountStr, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
|
||||
transactionAmount, err := parseAmount(transactionAmountStr)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parsing transaction in row#%d (dataset#%d), because transaction amount \"%s\" is invalid", t.currentIndexInDataset, t.currentDatasetIndex, transactionAmountStr)
|
||||
return false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
splitTotalAmount := int64(0)
|
||||
|
||||
for i := 0; i < len(transactionData.splitData); i++ {
|
||||
splitAmountStr, _ := dataset.getSplitDataItemValue(transactionData.splitData[i], iifTransactionAmountColumnName)
|
||||
splitAmount, err := parseAmount(splitAmountStr)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parsing transaction in row#%d-split#%d (dataset#%d), because split amount \"%s\" is invalid", t.currentIndexInDataset, i, t.currentDatasetIndex, splitAmountStr)
|
||||
return false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
splitTotalAmount += splitAmount
|
||||
}
|
||||
|
||||
if splitTotalAmount != -transactionAmount {
|
||||
supportSplitTransactions = false
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parse split transaction#%d (dataset#%d), because the sum amount of each split data \"%d\" not equal to the transaction amount \"%d\"", t.currentIndexInDataset, t.currentDatasetIndex, splitTotalAmount, -transactionAmount)
|
||||
}
|
||||
}
|
||||
|
||||
if len(transactionData.splitData) > 1 && !supportSplitTransactions {
|
||||
return false, errs.ErrNotSupportedSplitTransactions
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (t *iifTransactionDataRowIterator) parseTransactionTime(dataset *iifTransactionDataset, transactionData *iifTransactionData) (string, error) {
|
||||
date, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionDateColumnName)
|
||||
dateParts := strings.Split(date, "/")
|
||||
@@ -316,6 +447,10 @@ func (t *iifTransactionDataRowIterator) parseTransactionTime(dataset *iifTransac
|
||||
day = dateParts[2]
|
||||
}
|
||||
|
||||
if len(year) == 2 {
|
||||
year = utils.IntToString(time.Now().Year()/100) + year
|
||||
}
|
||||
|
||||
if len(month) < 2 {
|
||||
month = "0" + month
|
||||
}
|
||||
@@ -390,3 +525,7 @@ func getIncomeAndExpenseAccountNameMap(accountDatasets []*iifAccountDataset) (in
|
||||
|
||||
return incomeAccountNames, expenseAccountNames
|
||||
}
|
||||
|
||||
func parseAmount(amount string) (int64, error) {
|
||||
return utils.ParseAmount(strings.ReplaceAll(amount, ",", ""))
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const ofxUnicodeEncoding = "unicode"
|
||||
const ofxUSAsciiEncoding = "usascii"
|
||||
const ofx1USAsciiEncoding = "usascii"
|
||||
const ofx1UnicodeEncoding = "unicode"
|
||||
const ofx1UTF8Encoding = "utf8" // non-standard ofx 1.x encoding, used by some banks (https://github.com/mayswind/ezbookkeeping/issues/48)
|
||||
const ofx1SGMLDataFormat = "OFXSGML"
|
||||
|
||||
var ofx2HeaderPattern = regexp.MustCompile("<\\?OFX( +[A-Z]+=\"[^=]*\")* *\\?>")
|
||||
@@ -231,7 +232,7 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
|
||||
}
|
||||
}
|
||||
|
||||
if fileEncoding == ofxUSAsciiEncoding {
|
||||
if fileEncoding == ofx1USAsciiEncoding {
|
||||
if utils.IsStringOnlyContainsDigits(fileCharset) {
|
||||
fileCharset = "cp" + fileCharset
|
||||
}
|
||||
@@ -245,12 +246,18 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
|
||||
if enc == nil {
|
||||
enc = charmap.Windows1252
|
||||
}
|
||||
} else if fileEncoding == ofxUnicodeEncoding {
|
||||
enc, _ = charset.Lookup(ofxUnicodeEncoding)
|
||||
} else if fileEncoding == ofx1UnicodeEncoding {
|
||||
enc, _ = charset.Lookup(ofx1UnicodeEncoding)
|
||||
|
||||
if enc == nil {
|
||||
enc = unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)
|
||||
}
|
||||
} else if fileEncoding == ofx1UTF8Encoding {
|
||||
enc, _ = charset.Lookup(ofx1UTF8Encoding)
|
||||
|
||||
if enc == nil {
|
||||
enc = unicode.UTF8
|
||||
}
|
||||
} else {
|
||||
log.Errorf(ctx, "[ofx_data_reader.readOFX1FileHeader] cannot parse ofx 1.x file, because encoding \"%s\" is unknown", fileEncoding)
|
||||
return nil, nil, "", nil, errs.ErrInvalidOFXFile
|
||||
|
||||
+3
-1
@@ -105,6 +105,7 @@ func (t *ofxTransactionDataRowIterator) Next(ctx core.Context, user *models.User
|
||||
rowItems, err := t.parseTransaction(ctx, user, data)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[ofx_transaction_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -151,9 +152,10 @@ func (t *ofxTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
amount, err := utils.ParseAmount(strings.ReplaceAll(ofxTransaction.Amount, ",", ".")) // ofx supports decimal point or comma to indicate the start of the fractional amount
|
||||
amount, err := utils.ParseAmount(utils.TrimTrailingZerosInDecimal(strings.ReplaceAll(ofxTransaction.Amount, ",", "."))) // ofx supports decimal point or comma to indicate the start of the fractional amount
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[ofx_transaction_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", ofxTransaction.Amount, err.Error())
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ func (t *qifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
|
||||
rowItems, err := t.parseTransaction(ctx, user, data)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[qif_transaction_data_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package converters
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/alipay"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/base"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/default"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/dsv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/feidee"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/qif"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/wechat"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
// GetTransactionDataExporter returns the transaction data exporter according to the file type
|
||||
@@ -61,3 +64,18 @@ func GetTransactionDataImporter(fileType string) (base.TransactionDataImporter,
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
// IsCustomDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
|
||||
func IsCustomDelimiterSeparatedValuesFileType(fileType string) bool {
|
||||
return dsv.IsDelimiterSeparatedValuesFileType(fileType)
|
||||
}
|
||||
|
||||
// CreateNewDelimiterSeparatedValuesDataParser returns a new delimiter-separated values data parser according to the file type and encoding
|
||||
func CreateNewDelimiterSeparatedValuesDataParser(fileType string, fileEncoding string) (dsv.CustomTransactionDataDsvFileParser, error) {
|
||||
return dsv.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
|
||||
}
|
||||
|
||||
// CreateNewDelimiterSeparatedValuesDataImporter returns a new delimiter-separated values data importer according to the file type and encoding
|
||||
func CreateNewDelimiterSeparatedValuesDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, geoLocationSeparator string, transactionTagSeparator string) (base.TransactionDataImporter, error) {
|
||||
return dsv.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, geoLocationSeparator, transactionTagSeparator)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func (t *weChatPayTransactionDataRowParser) Parse(ctx core.Context, user *models
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.WeChatWallet
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
|
||||
} else {
|
||||
log.Warnf(ctx, "[wechat_pay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because unkown transfer transaction category \"%s\"", rowId, data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY])
|
||||
log.Warnf(ctx, "[wechat_pay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because unknown transfer transaction category \"%s\"", rowId, data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY])
|
||||
return nil, false, nil
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -12,7 +12,6 @@ const (
|
||||
DECIMAL_SEPARATOR_DEFAULT DecimalSeparator = 0
|
||||
DECIMAL_SEPARATOR_DOT DecimalSeparator = 1
|
||||
DECIMAL_SEPARATOR_COMMA DecimalSeparator = 2
|
||||
DECIMAL_SEPARATOR_SPACE DecimalSeparator = 3
|
||||
DECIMAL_SEPARATOR_INVALID DecimalSeparator = 255
|
||||
)
|
||||
|
||||
@@ -25,8 +24,6 @@ func (f DecimalSeparator) String() string {
|
||||
return "Dot"
|
||||
case DECIMAL_SEPARATOR_COMMA:
|
||||
return "Comma"
|
||||
case DECIMAL_SEPARATOR_SPACE:
|
||||
return "Space"
|
||||
case DECIMAL_SEPARATOR_INVALID:
|
||||
return "Invalid"
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserFeatureRestrictions represents all the restrictions of user features
|
||||
type UserFeatureRestrictions uint64
|
||||
|
||||
// Add returns a new feature restrictions with the specified feature
|
||||
func (r UserFeatureRestrictions) Add(featureRestrictionType UserFeatureRestrictionType) UserFeatureRestrictions {
|
||||
typeValue := uint64(1 << (featureRestrictionType - 1))
|
||||
return UserFeatureRestrictions(uint64(r) | typeValue)
|
||||
}
|
||||
|
||||
// Remove returns a new feature restrictions without the specified feature
|
||||
func (r UserFeatureRestrictions) Remove(featureRestrictionType UserFeatureRestrictionType) UserFeatureRestrictions {
|
||||
typeValue := uint64(1 << (featureRestrictionType - 1))
|
||||
return UserFeatureRestrictions(uint64(r) & (^typeValue))
|
||||
}
|
||||
|
||||
// Contains returns whether contains the specified feature
|
||||
func (r UserFeatureRestrictions) Contains(featureRestrictionType UserFeatureRestrictionType) bool {
|
||||
typeValue := uint64(1 << (featureRestrictionType - 1))
|
||||
return uint64(r)&typeValue == typeValue
|
||||
}
|
||||
|
||||
// String returns a textual representation of all the restrictions of user features
|
||||
func (r UserFeatureRestrictions) String() string {
|
||||
builder := strings.Builder{}
|
||||
|
||||
for restrictionType := USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD; restrictionType <= USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA; restrictionType++ {
|
||||
if !r.Contains(restrictionType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteRune(',')
|
||||
}
|
||||
|
||||
builder.WriteString(restrictionType.String())
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// ParseUserFeatureRestrictions returns restrictions of user features according to the textual restrictions of user features separated by commas
|
||||
func ParseUserFeatureRestrictions(featureRestrictions string) UserFeatureRestrictions {
|
||||
if len(featureRestrictions) < 1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
restrictions := uint64(0)
|
||||
typeValues := strings.Split(featureRestrictions, ",")
|
||||
|
||||
for i := 0; i < len(typeValues); i++ {
|
||||
value, err := strconv.ParseInt(typeValues[i], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if uint64(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD) <= uint64(value) && uint64(value) <= uint64(USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
typeValue := uint64(1 << (value - 1))
|
||||
restrictions = restrictions | typeValue
|
||||
}
|
||||
}
|
||||
|
||||
return UserFeatureRestrictions(restrictions)
|
||||
}
|
||||
|
||||
// UserFeatureRestrictionType represents the restriction type of user features
|
||||
type UserFeatureRestrictionType uint64
|
||||
|
||||
// User Feature Restriction Type
|
||||
const (
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD UserFeatureRestrictionType = 1
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL UserFeatureRestrictionType = 2
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO UserFeatureRestrictionType = 3
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR UserFeatureRestrictionType = 4
|
||||
USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION UserFeatureRestrictionType = 5
|
||||
USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA UserFeatureRestrictionType = 6
|
||||
USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA UserFeatureRestrictionType = 7
|
||||
USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD UserFeatureRestrictionType = 8
|
||||
USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION UserFeatureRestrictionType = 9
|
||||
USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION UserFeatureRestrictionType = 10
|
||||
USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA UserFeatureRestrictionType = 11
|
||||
)
|
||||
|
||||
// String returns a textual representation of the restriction type of user features
|
||||
func (t UserFeatureRestrictionType) String() string {
|
||||
switch t {
|
||||
case USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD:
|
||||
return "Update Password"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL:
|
||||
return "Update Email"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO:
|
||||
return "Update Profile Basic Info"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR:
|
||||
return "Update Avatar"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION:
|
||||
return "Logout Other Session"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA:
|
||||
return "Enable Two-Factor Authentication"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA:
|
||||
return "Disable Enable Two-Factor Authentication"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD:
|
||||
return "Forget Password"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION:
|
||||
return "Import Transactions"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION:
|
||||
return "Export Transactions"
|
||||
case USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA:
|
||||
return "Clear All Data"
|
||||
default:
|
||||
return fmt.Sprintf("Invalid(%d)", int(t))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserFeatureRestrictionsAdd(t *testing.T) {
|
||||
var featureRestrictions UserFeatureRestrictions
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD)
|
||||
expectedValue := UserFeatureRestrictions(1)
|
||||
assert.Equal(t, expectedValue, featureRestrictions)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD)
|
||||
expectedValue = UserFeatureRestrictions(255)
|
||||
assert.Equal(t, expectedValue, featureRestrictions)
|
||||
}
|
||||
|
||||
func TestUserFeatureRestrictionsRemove(t *testing.T) {
|
||||
var featureRestrictions UserFeatureRestrictions
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION)
|
||||
expectedValue := UserFeatureRestrictions(1)
|
||||
assert.Equal(t, expectedValue, featureRestrictions)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Remove(USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA)
|
||||
expectedValue = UserFeatureRestrictions(153)
|
||||
assert.Equal(t, expectedValue, featureRestrictions)
|
||||
}
|
||||
|
||||
func TestUserFeatureRestrictionsContains(t *testing.T) {
|
||||
var featureRestrictions UserFeatureRestrictions
|
||||
assert.False(t, featureRestrictions.Contains(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD))
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD)
|
||||
assert.True(t, featureRestrictions.Contains(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD))
|
||||
assert.False(t, featureRestrictions.Contains(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO))
|
||||
}
|
||||
|
||||
func TestUserFeatureRestrictionsString(t *testing.T) {
|
||||
var featureRestrictions UserFeatureRestrictions
|
||||
expectedValue := ""
|
||||
actualValue := featureRestrictions.String()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD)
|
||||
expectedValue = "Update Password"
|
||||
actualValue = featureRestrictions.String()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD)
|
||||
expectedValue = "Update Password,Forget Password"
|
||||
actualValue = featureRestrictions.String()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION)
|
||||
featureRestrictions = featureRestrictions.Add(USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA)
|
||||
expectedValue = "Update Password," +
|
||||
"Update Email," +
|
||||
"Update Profile Basic Info," +
|
||||
"Update Avatar," +
|
||||
"Logout Other Session," +
|
||||
"Enable Two-Factor Authentication," +
|
||||
"Disable Enable Two-Factor Authentication," +
|
||||
"Forget Password," +
|
||||
"Import Transactions," +
|
||||
"Export Transactions," +
|
||||
"Clear All Data"
|
||||
actualValue = featureRestrictions.String()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestParseUserFeatureRestrictions(t *testing.T) {
|
||||
expectedValue := UserFeatureRestrictions(0)
|
||||
actualValue := ParseUserFeatureRestrictions("")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = UserFeatureRestrictions(1)
|
||||
actualValue = ParseUserFeatureRestrictions("1")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = UserFeatureRestrictions(1)
|
||||
actualValue = ParseUserFeatureRestrictions("1,20")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = UserFeatureRestrictions(255)
|
||||
actualValue = ParseUserFeatureRestrictions("1,2,3,4,5,6,7,8,20,21,22")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = UserFeatureRestrictions(255)
|
||||
actualValue = ParseUserFeatureRestrictions("1,2,3,4,5,6,7,8,a,b,20")
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
@@ -15,22 +15,22 @@ type GocronLoggerAdapter struct {
|
||||
|
||||
// Debug logs debug log
|
||||
func (logger GocronLoggerAdapter) Debug(msg string, args ...any) {
|
||||
log.Debugf(core.NewNullContext(), logger.getFinalLog(msg, args...))
|
||||
log.Debugf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
|
||||
}
|
||||
|
||||
// Info logs info log
|
||||
func (logger GocronLoggerAdapter) Info(msg string, args ...any) {
|
||||
log.Infof(core.NewNullContext(), logger.getFinalLog(msg, args...))
|
||||
log.Infof(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
|
||||
}
|
||||
|
||||
// Warn logs warn log
|
||||
func (logger GocronLoggerAdapter) Warn(msg string, args ...any) {
|
||||
log.Warnf(core.NewNullContext(), logger.getFinalLog(msg, args...))
|
||||
log.Warnf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
|
||||
}
|
||||
|
||||
// Error logs error log
|
||||
func (logger GocronLoggerAdapter) Error(msg string, args ...any) {
|
||||
log.Errorf(core.NewNullContext(), logger.getFinalLog(msg, args...))
|
||||
log.Errorf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
|
||||
}
|
||||
|
||||
func (logger GocronLoggerAdapter) getFinalLog(msg string, args ...any) string {
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// Database represents a database instance
|
||||
type Database struct {
|
||||
engineGroup *xorm.EngineGroup
|
||||
databaseType string
|
||||
engineGroup *xorm.EngineGroup
|
||||
}
|
||||
|
||||
// NewSession starts a new session with the specified context
|
||||
@@ -41,3 +43,23 @@ func (db *Database) DoTransaction(c core.Context, fn func(sess *xorm.Session) er
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSavePoint sets a save point in the current transaction for Postgres
|
||||
func (db *Database) SetSavePoint(sess *xorm.Session, savePointName string) error {
|
||||
if db.databaseType == settings.PostgresDbType {
|
||||
_, err := sess.Exec("SAVEPOINT " + savePointName)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackToSavePoint rolls back to the specified save point in the current transaction for Postgres
|
||||
func (db *Database) RollbackToSavePoint(sess *xorm.Session, savePointName string) error {
|
||||
if db.databaseType == settings.PostgresDbType {
|
||||
_, err := sess.Exec("ROLLBACK TO SAVEPOINT " + savePointName)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ func initializeDatabase(dbConfig *settings.DatabaseConfig) (*Database, error) {
|
||||
engineGroup.SetConnMaxLifetime(time.Duration(dbConfig.ConnectionMaxLifeTime) * time.Second)
|
||||
|
||||
return &Database{
|
||||
engineGroup: engineGroup,
|
||||
databaseType: dbConfig.DatabaseType,
|
||||
engineGroup: engineGroup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,4 +8,6 @@ type DuplicateChecker interface {
|
||||
SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string)
|
||||
GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string)
|
||||
RemoveCronJobRunningInfo(jobName string)
|
||||
GetFailureCount(failureKey string) uint32
|
||||
IncreaseFailureCount(failureKey string) uint32
|
||||
}
|
||||
|
||||
@@ -48,3 +48,13 @@ func (c *DuplicateCheckerContainer) GetOrSetCronJobRunningInfo(jobName string, r
|
||||
func (c *DuplicateCheckerContainer) RemoveCronJobRunningInfo(jobName string) {
|
||||
c.Current.RemoveCronJobRunningInfo(jobName)
|
||||
}
|
||||
|
||||
// GetFailureCount returns the failure count of the specified failure key
|
||||
func (c *DuplicateCheckerContainer) GetFailureCount(failureKey string) uint32 {
|
||||
return c.Current.GetFailureCount(failureKey)
|
||||
}
|
||||
|
||||
// IncreaseFailureCount increases the failure count of the specified failure key
|
||||
func (c *DuplicateCheckerContainer) IncreaseFailureCount(failureKey string) uint32 {
|
||||
return c.Current.IncreaseFailureCount(failureKey)
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ const (
|
||||
DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 4
|
||||
DUPLICATE_CHECKER_TYPE_NEW_PICTURE DuplicateCheckerType = 5
|
||||
DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS DuplicateCheckerType = 6
|
||||
DUPLICATE_CHECKER_TYPE_FAILURE_CHECK DuplicateCheckerType = 255
|
||||
)
|
||||
|
||||
@@ -69,6 +69,34 @@ func (c *InMemoryDuplicateChecker) RemoveCronJobRunningInfo(jobName string) {
|
||||
c.cache.Delete(c.getCacheKey(DUPLICATE_CHECKER_TYPE_BACKGROUND_CRON_JOB, 0, jobName))
|
||||
}
|
||||
|
||||
// GetFailureCount returns the failure count of the specified failure key
|
||||
func (c *InMemoryDuplicateChecker) GetFailureCount(failureKey string) uint32 {
|
||||
existedFailureCount, found := c.cache.Get(c.getCacheKey(DUPLICATE_CHECKER_TYPE_FAILURE_CHECK, 0, failureKey))
|
||||
|
||||
if found {
|
||||
return existedFailureCount.(uint32)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// IncreaseFailureCount increases the failure count of the specified failure key
|
||||
func (c *InMemoryDuplicateChecker) IncreaseFailureCount(failureKey string) uint32 {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
cacheKey := c.getCacheKey(DUPLICATE_CHECKER_TYPE_FAILURE_CHECK, 0, failureKey)
|
||||
_, found := c.cache.Get(cacheKey)
|
||||
|
||||
if found {
|
||||
failureCount, _ := c.cache.IncrementUint32(cacheKey, uint32(1))
|
||||
return failureCount
|
||||
} else {
|
||||
c.cache.Set(cacheKey, uint32(1), 1*time.Minute)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func (c *InMemoryDuplicateChecker) getCacheKey(checkerType DuplicateCheckerType, uid int64, identification string) string {
|
||||
return fmt.Sprintf("%d|%d|%s", checkerType, uid, identification)
|
||||
}
|
||||
|
||||
@@ -155,3 +155,77 @@ func TestGetOrSetRunningInfoConcurrent(t *testing.T) {
|
||||
|
||||
assert.Equal(t, uint32(999), setRunningInfoCount.Load())
|
||||
}
|
||||
|
||||
func TestGetFailureCount(t *testing.T) {
|
||||
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
|
||||
DuplicateSubmissionsIntervalDuration: time.Second,
|
||||
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
|
||||
})
|
||||
|
||||
failureKey := "127.0.0.1"
|
||||
|
||||
failureCount := checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(0), failureCount)
|
||||
|
||||
failureCount = checker.IncreaseFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(1), failureCount)
|
||||
|
||||
failureCount = checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(1), failureCount)
|
||||
}
|
||||
|
||||
func TestIncreaseFailureCount(t *testing.T) {
|
||||
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
|
||||
DuplicateSubmissionsIntervalDuration: time.Second,
|
||||
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
|
||||
})
|
||||
|
||||
failureKey := "127.0.0.1"
|
||||
|
||||
failureCount := checker.IncreaseFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(1), failureCount)
|
||||
|
||||
failureCount = checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(1), failureCount)
|
||||
|
||||
failureCount = checker.IncreaseFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(2), failureCount)
|
||||
|
||||
failureCount = checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(2), failureCount)
|
||||
|
||||
failureCount = checker.IncreaseFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(3), failureCount)
|
||||
|
||||
failureCount = checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(3), failureCount)
|
||||
}
|
||||
|
||||
func TestIncreaseFailureCountConcurrent(t *testing.T) {
|
||||
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
|
||||
DuplicateSubmissionsIntervalDuration: time.Second,
|
||||
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
|
||||
})
|
||||
|
||||
failureKey := "127.0.0.1"
|
||||
|
||||
concurrentCount := 10
|
||||
var waitGroup sync.WaitGroup
|
||||
|
||||
for routineIndex := 0; routineIndex < concurrentCount; routineIndex++ {
|
||||
waitGroup.Add(1)
|
||||
|
||||
go func(currentRoutineIndex int) {
|
||||
for cycle := 0; cycle < 10; cycle++ {
|
||||
checker.IncreaseFailureCount(failureKey)
|
||||
}
|
||||
|
||||
waitGroup.Done()
|
||||
}(routineIndex)
|
||||
}
|
||||
|
||||
waitGroup.Wait()
|
||||
|
||||
failureCount := checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(100), failureCount)
|
||||
}
|
||||
|
||||
@@ -19,4 +19,7 @@ var (
|
||||
ErrDestinationAccountNotFound = NewNormalError(NormalSubcategoryAccount, 12, http.StatusBadRequest, "destination account not found")
|
||||
ErrAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 13, http.StatusBadRequest, "account is in use and cannot be deleted")
|
||||
ErrAccountCategoryInvalid = NewNormalError(NormalSubcategoryAccount, 14, http.StatusBadRequest, "account category is invalid")
|
||||
ErrAccountBalanceTimeNotSet = NewNormalError(NormalSubcategoryAccount, 15, http.StatusBadRequest, "account balance time is not set")
|
||||
ErrCannotSetStatementDateForNonCreditCard = NewNormalError(NormalSubcategoryAccount, 16, http.StatusBadRequest, "cannot set statement date for non credit card account")
|
||||
ErrCannotSetStatementDateForSubAccount = NewNormalError(NormalSubcategoryAccount, 17, http.StatusBadRequest, "cannot set statement date for sub account")
|
||||
)
|
||||
|
||||
+36
-3
@@ -2,25 +2,58 @@ package errs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestErrorError(t *testing.T) {
|
||||
err := New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "error message")
|
||||
assert.EqualError(t, err, "error message")
|
||||
}
|
||||
|
||||
func TestErrorCode(t *testing.T) {
|
||||
err := New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "error message")
|
||||
assert.Equal(t, int32(112034), err.Code())
|
||||
}
|
||||
|
||||
func TestMultiError(t *testing.T) {
|
||||
err1 := errors.New("error1 message")
|
||||
err2 := errors.New("error2 message")
|
||||
err := NewMultiErrorOrNil(err1, err2)
|
||||
assert.Equal(t, "multi errors: error1 message, error2 message", err.Error())
|
||||
assert.EqualError(t, err, "multi errors: error1 message, error2 message")
|
||||
}
|
||||
|
||||
func TestNewMultiErrorOrNilWithOnlyOneParamerter(t *testing.T) {
|
||||
func TestNewMultiErrorOrNilWithOnlyOneParameter(t *testing.T) {
|
||||
err1 := errors.New("error1 message")
|
||||
err := NewMultiErrorOrNil(err1)
|
||||
assert.Equal(t, err1, err)
|
||||
assert.EqualError(t, err, "error1 message")
|
||||
}
|
||||
|
||||
func TestNewMultiErrorOrNilWithoutOneParamerter(t *testing.T) {
|
||||
func TestNewMultiErrorOrNilWithoutOneParameter(t *testing.T) {
|
||||
err := NewMultiErrorOrNil()
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestOr(t *testing.T) {
|
||||
err1 := errors.New("test error")
|
||||
err2 := New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "test custom error")
|
||||
err := Or(err1, err2)
|
||||
assert.Equal(t, err2, err)
|
||||
assert.EqualError(t, err, "test custom error")
|
||||
|
||||
err1 = New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "test custom error1")
|
||||
err2 = New(CATEGORY_SYSTEM, 23, 45, http.StatusInternalServerError, "test custom error2")
|
||||
err = Or(err1, err2)
|
||||
assert.Equal(t, err1, err)
|
||||
assert.EqualError(t, err, "test custom error1")
|
||||
}
|
||||
|
||||
func TestIsCustomError(t *testing.T) {
|
||||
err1 := errors.New("test error")
|
||||
err2 := New(CATEGORY_SYSTEM, 12, 34, http.StatusInternalServerError, "test custom error")
|
||||
assert.False(t, IsCustomError(err1))
|
||||
assert.True(t, IsCustomError(err2))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ var (
|
||||
ErrNoFilesUpload = NewNormalError(NormalSubcategoryGlobal, 15, http.StatusBadRequest, "no files uploaded")
|
||||
ErrUploadedFileEmpty = NewNormalError(NormalSubcategoryGlobal, 16, http.StatusBadRequest, "uploaded file is empty")
|
||||
ErrExceedMaxUploadFileSize = NewNormalError(NormalSubcategoryGlobal, 17, http.StatusBadRequest, "uploaded file size exceeds the maximum allowed size")
|
||||
ErrFailureCountLimitReached = NewNormalError(NormalSubcategoryGlobal, 18, http.StatusBadRequest, "failure count exceeded maximum limit")
|
||||
)
|
||||
|
||||
// GetParameterInvalidMessage returns specific error message for invalid parameter error
|
||||
|
||||
@@ -35,4 +35,10 @@ var (
|
||||
ErrCannotAddTransactionBeforeBalanceModificationTransaction = NewSystemError(NormalSubcategoryTransaction, 28, http.StatusBadRequest, "cannot add transaction before balance modification transaction")
|
||||
ErrBalanceModificationTransactionCannotModifyTime = NewSystemError(NormalSubcategoryTransaction, 29, http.StatusBadRequest, "balance modification transaction cannot modify transaction time")
|
||||
ErrTransferTransactionAmountCannotBeLessThanZero = NewNormalError(NormalSubcategoryTransaction, 30, http.StatusBadRequest, "transfer transaction amount cannot be less than zero")
|
||||
ErrImportFileEncodingIsEmpty = NewSystemError(NormalSubcategoryTransaction, 31, http.StatusBadRequest, "import file encoding is empty")
|
||||
ErrImportFileEncodingNotSupported = NewSystemError(NormalSubcategoryTransaction, 32, http.StatusBadRequest, "import file encoding not supported")
|
||||
ErrImportFileColumnMappingInvalid = NewSystemError(NormalSubcategoryTransaction, 33, http.StatusBadRequest, "column mapping invalid")
|
||||
ErrImportFileTransactionTypeMappingInvalid = NewSystemError(NormalSubcategoryTransaction, 34, http.StatusBadRequest, "transaction type mapping invalid")
|
||||
ErrImportFileTransactionTimeFormatInvalid = NewSystemError(NormalSubcategoryTransaction, 35, http.StatusBadRequest, "transaction time format invalid")
|
||||
ErrImportFileTransactionTimezoneFormatInvalid = NewSystemError(NormalSubcategoryTransaction, 36, http.StatusBadRequest, "transaction time zone format invalid")
|
||||
)
|
||||
|
||||
@@ -4,10 +4,11 @@ import "net/http"
|
||||
|
||||
// Error codes related to transaction templates
|
||||
var (
|
||||
ErrTransactionTemplateIdInvalid = NewNormalError(NormalSubcategoryTemplate, 0, http.StatusBadRequest, "transaction template id is invalid")
|
||||
ErrTransactionTemplateNotFound = NewNormalError(NormalSubcategoryTemplate, 1, http.StatusBadRequest, "transaction template not found")
|
||||
ErrTransactionTemplateTypeInvalid = NewNormalError(NormalSubcategoryTemplate, 2, http.StatusBadRequest, "transaction template type is invalid")
|
||||
ErrScheduledTransactionNotEnabled = NewNormalError(NormalSubcategoryTemplate, 3, http.StatusBadRequest, "scheduled transaction is not enabled")
|
||||
ErrScheduledTransactionFrequencyInvalid = NewNormalError(NormalSubcategoryTemplate, 4, http.StatusBadRequest, "scheduled transaction frequency is invalid")
|
||||
ErrTransactionTemplateHasTooManyTags = NewNormalError(NormalSubcategoryTemplate, 5, http.StatusBadRequest, "transaction template has too many tags")
|
||||
ErrTransactionTemplateIdInvalid = NewNormalError(NormalSubcategoryTemplate, 0, http.StatusBadRequest, "transaction template id is invalid")
|
||||
ErrTransactionTemplateNotFound = NewNormalError(NormalSubcategoryTemplate, 1, http.StatusBadRequest, "transaction template not found")
|
||||
ErrTransactionTemplateTypeInvalid = NewNormalError(NormalSubcategoryTemplate, 2, http.StatusBadRequest, "transaction template type is invalid")
|
||||
ErrScheduledTransactionNotEnabled = NewNormalError(NormalSubcategoryTemplate, 3, http.StatusBadRequest, "scheduled transaction is not enabled")
|
||||
ErrScheduledTransactionFrequencyInvalid = NewNormalError(NormalSubcategoryTemplate, 4, http.StatusBadRequest, "scheduled transaction frequency is invalid")
|
||||
ErrTransactionTemplateHasTooManyTags = NewNormalError(NormalSubcategoryTemplate, 5, http.StatusBadRequest, "transaction template has too many tags")
|
||||
ErrScheduledTransactionTemplateStartDataLaterThanEndDate = NewNormalError(NormalSubcategoryTemplate, 6, http.StatusBadRequest, "scheduled transaction start date is later than end time")
|
||||
)
|
||||
|
||||
@@ -37,4 +37,5 @@ var (
|
||||
ErrUserAvatarNotSet = NewNormalError(NormalSubcategoryUser, 28, http.StatusNotFound, "user avatar not set")
|
||||
ErrUserAvatarExtensionInvalid = NewNormalError(NormalSubcategoryUser, 29, http.StatusNotFound, "user avatar file extension invalid")
|
||||
ErrExceedMaxUserAvatarFileSize = NewNormalError(NormalSubcategoryUser, 30, http.StatusBadRequest, "exceed the maximum size of user avatar file")
|
||||
ErrNotPermittedToPerformThisAction = NewNormalError(NormalSubcategoryUser, 31, http.StatusBadRequest, "not permitted to perform this action")
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package exchangerates
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -129,9 +130,15 @@ func (e *BankOfCanadaExchangeRateData) ToLatestExchangeRateResponse(c core.Conte
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the bank of Canada data source urls
|
||||
func (e *BankOfCanadaDataSource) GetRequestUrls() []string {
|
||||
return []string{bankOfCanadaExchangeRateUrl}
|
||||
// BuildRequests returns the bank of Canada exchange rates http requests
|
||||
func (e *BankOfCanadaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", bankOfCanadaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the bank of Canada data source raw response
|
||||
|
||||
@@ -38,6 +38,15 @@ func TestBankOfCanadaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
assert.Equal(t, "CAD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestBankOfCanadaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &BankOfCanadaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfCanadaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617309000), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestBankOfCanadaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &BankOfCanadaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -172,4 +181,17 @@ func TestBankOfCanadaDataSource_InvalidRate(t *testing.T) {
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("{"+
|
||||
" \"observations\": [\n"+
|
||||
" {\n"+
|
||||
" \"d\": \"2021-04-01\",\n"+
|
||||
" \"FXUSDCAD\": {\n"+
|
||||
" \"v\": \"0\"\n"+
|
||||
" }\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const bankOfIsraelExchangeRateUrl = "https://boi.org.il/PublicApi/GetExchangeRates?asXml=true"
|
||||
const bankOfIsraelExchangeRateReferenceUrl = "https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/"
|
||||
const bankOfIsraelDataSource = "בנק ישראל"
|
||||
const bankOfIsraelBaseCurrency = "ILS"
|
||||
|
||||
const bankOfIsraelDataUpdateDateFormat = "2006-01-02T15:04:05.9999999Z"
|
||||
|
||||
// BankOfIsraelDataSource defines the structure of exchange rates data source of bank of Israel
|
||||
type BankOfIsraelDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// BankOfIsraelExchangeRateData represents the whole data from bank of Israel
|
||||
type BankOfIsraelExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"ExchangeRatesResponseCollectioDTO"`
|
||||
AllExchangeRates []*BankOfIsraelExchangeRate `xml:"ExchangeRates>ExchangeRateResponseDTO"`
|
||||
}
|
||||
|
||||
// BankOfIsraelExchangeRate represents the exchange rate data from bank of Israel
|
||||
type BankOfIsraelExchangeRate struct {
|
||||
Currency string `xml:"Key"`
|
||||
Rate string `xml:"CurrentExchangeRate"`
|
||||
LastUpdate string `xml:"LastUpdate"`
|
||||
Unit string `xml:"Unit"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from bank of Israel
|
||||
func (e *BankOfIsraelExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e.AllExchangeRates) < 1 {
|
||||
log.Errorf(c, "[bank_of_israel_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestUpdateDate := ""
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.AllExchangeRates))
|
||||
|
||||
for i := 0; i < len(e.AllExchangeRates); i++ {
|
||||
exchangeRate := e.AllExchangeRates[i]
|
||||
|
||||
if latestUpdateDate == "" {
|
||||
latestUpdateDate = exchangeRate.LastUpdate
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
updateTime, err := time.Parse(bankOfIsraelDataUpdateDateFormat, latestUpdateDate)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[bank_of_israel_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", latestUpdateDate)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: bankOfIsraelDataSource,
|
||||
ReferenceUrl: bankOfIsraelExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: bankOfIsraelBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from bank of Israel
|
||||
func (e *BankOfIsraelExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit, err := utils.StringToFloat64(e.Unit)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
if unit <= 0 {
|
||||
log.Warnf(c, "[bank_of_israel_datasource.ToLatestExchangeRate] unit is less or equal zero, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := unit / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the bank of Israel exchange rates http requests
|
||||
func (e *BankOfIsraelDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", bankOfIsraelExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the bank of Israel data source raw response
|
||||
func (e *BankOfIsraelDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
bankOfIsraelData := &BankOfIsraelExchangeRateData{}
|
||||
err := xmlDecoder.Decode(bankOfIsraelData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[bank_of_israel_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := bankOfIsraelData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[bank_of_israel_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const bankOfIsraelMinimumRequiredContent = "" +
|
||||
"<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n" +
|
||||
" <ExchangeRates>\n" +
|
||||
" <ExchangeRateResponseDTO>\n" +
|
||||
" <CurrentExchangeRate>3.733</CurrentExchangeRate>\n" +
|
||||
" <Key>USD</Key>\n" +
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n" +
|
||||
" <Unit>1</Unit>\n" +
|
||||
" </ExchangeRateResponseDTO>\n" +
|
||||
" <ExchangeRateResponseDTO>\n" +
|
||||
" <CurrentExchangeRate>2.4287</CurrentExchangeRate>\n" +
|
||||
" <Key>JPY</Key>\n" +
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n" +
|
||||
" <Unit>100</Unit>\n" +
|
||||
" </ExchangeRateResponseDTO>\n" +
|
||||
" </ExchangeRates>\n" +
|
||||
"</ExchangeRatesResponseCollectioDTO>"
|
||||
|
||||
func TestBankOfIsraelDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfIsraelMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "ILS", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfIsraelMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731331565), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfIsraelMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.2678810608090008",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "41.17429077284144",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_EmptyExchangeRatesResponseCollectioDTO(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>1</CurrentExchangeRate>\n"+
|
||||
" <Key>XXX</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>1</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>1</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>null</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>1</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>0</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>1</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_EmptyUnit(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>1</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestBankOfIsraelDataSource_InvalidUnit(t *testing.T) {
|
||||
dataSource := &BankOfIsraelDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>1</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>null</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<ExchangeRatesResponseCollectioDTO xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://schemas.datacontract.org/2004/07/BOI.Core.Models.HotData\">\n"+
|
||||
" <ExchangeRates>\n"+
|
||||
" <ExchangeRateResponseDTO>\n"+
|
||||
" <CurrentExchangeRate>1</CurrentExchangeRate>\n"+
|
||||
" <Key>USD</Key>\n"+
|
||||
" <LastUpdate>2024-11-11T13:26:05.6590204Z</LastUpdate>\n"+
|
||||
" <Unit>0</Unit>\n"+
|
||||
" </ExchangeRateResponseDTO>\n"+
|
||||
" </ExchangeRates>\n"+
|
||||
"</ExchangeRatesResponseCollectioDTO>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const bankOfRussiaExchangeRateUrl = "https://cbr.ru/scripts/XML_daily_eng.asp"
|
||||
const bankOfRussiaExchangeRateReferenceUrl = "https://www.cbr.ru/eng/currency_base/daily/"
|
||||
const bankOfRussiaDataSource = "Банк России"
|
||||
const bankOfRussiaBaseCurrency = "RUB"
|
||||
|
||||
const bankOfRussiaUpdateDateFormat = "02.01.2006 15:04"
|
||||
const bankOfRussiaUpdateDateTimezone = "Europe/Moscow"
|
||||
|
||||
// BankOfRussiaDataSource defines the structure of exchange rates data source of bank of Russia
|
||||
type BankOfRussiaDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// BankOfRussiaExchangeRateData represents the whole data from bank of Russia
|
||||
type BankOfRussiaExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"ValCurs"`
|
||||
Date string `xml:"Date,attr"`
|
||||
ExchangeRates []*BankOfRussiaExchangeRate `xml:"Valute"`
|
||||
}
|
||||
|
||||
// BankOfRussiaExchangeRate represents the exchange rate data from bank of Russia
|
||||
type BankOfRussiaExchangeRate struct {
|
||||
Currency string `xml:"CharCode"`
|
||||
Rate string `xml:"VunitRate"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from bank of Russia
|
||||
func (e *BankOfRussiaExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.ExchangeRates))
|
||||
|
||||
for i := 0; i < len(e.ExchangeRates); i++ {
|
||||
exchangeRate := e.ExchangeRates[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(bankOfRussiaUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", bankOfRussiaUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
updateDateTime := e.Date + " 15:30" // the Bank of Russia switches to setting official exchange rates of foreign currencies against the ruble as of 15:30 Moscow time.
|
||||
updateTime, err := time.ParseInLocation(bankOfRussiaUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: bankOfRussiaDataSource,
|
||||
ReferenceUrl: bankOfRussiaExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: bankOfRussiaBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from bank of Russia
|
||||
func (e *BankOfRussiaExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(strings.ReplaceAll(e.Rate, ",", "."))
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[bank_of_russia_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[bank_of_russia_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 1 / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the bank of Russia exchange rates http requests
|
||||
func (e *BankOfRussiaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", bankOfRussiaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the bank of Russia data source raw response
|
||||
func (e *BankOfRussiaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
bankOfRussiaData := &BankOfRussiaExchangeRateData{}
|
||||
err := xmlDecoder.Decode(bankOfRussiaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := bankOfRussiaData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[bank_of_russia_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const bankOfRussiaDataSourceMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"windows-1251\"?>\n" +
|
||||
"<ValCurs Date=\"16.11.2024\">\n" +
|
||||
" <Valute>\n" +
|
||||
" <CharCode>USD</CharCode>\n" +
|
||||
" <VunitRate>99,9971</VunitRate>\n" +
|
||||
" </Valute>\n" +
|
||||
" <Valute>\n" +
|
||||
" <CharCode>CNY</CharCode>\n" +
|
||||
" <VunitRate>13,7992</VunitRate>\n" +
|
||||
" </Valute>\n" +
|
||||
"</ValCurs>"
|
||||
|
||||
func TestBankOfRussiaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "RUB", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731760200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(bankOfRussiaDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.010000290008410243",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "CNY",
|
||||
Rate: "0.07246796915763232",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_EmptyExchangeRatesDataset(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
"</ValCurs>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
" <Valute>\n"+
|
||||
" <CharCode>XXX</CharCode>\n"+
|
||||
" <VunitRate>1</VunitRate>\n"+
|
||||
" </Valute>\n"+
|
||||
"</ValCurs>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
" <Valute>\n"+
|
||||
" <CharCode>USD</CharCode>\n"+
|
||||
" <VunitRate></VunitRate>\n"+
|
||||
" </Valute>\n"+
|
||||
"</ValCurs>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestBankOfRussiaDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &BankOfRussiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
" <Valute>\n"+
|
||||
" <CharCode>USD</CharCode>\n"+
|
||||
" <VunitRate>null</VunitRate>\n"+
|
||||
" </Valute>\n"+
|
||||
"</ValCurs>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"windows-1251\"?>"+
|
||||
"<ValCurs Date=\"16.11.2024\">"+
|
||||
" <Valute>\n"+
|
||||
" <CharCode>USD</CharCode>\n"+
|
||||
" <VunitRate>0</VunitRate>\n"+
|
||||
" </Valute>\n"+
|
||||
"</ValCurs>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const centralBankOfHungaryExchangeRateServiceUrl = "http://www.mnb.hu/arfolyamok.asmx"
|
||||
const centralBankOfHungaryExchangeRateServiceCurrentExchangeRatesSoapAction = "http://www.mnb.hu/webservices/MNBArfolyamServiceSoap/GetCurrentExchangeRates"
|
||||
const centralBankOfHungaryExchangeRateReferenceUrl = "https://www.mnb.hu/en/arfolyamok"
|
||||
const centralBankOfHungaryDataSource = "Magyar Nemzeti Bank"
|
||||
const centralBankOfHungaryBaseCurrency = "HUF"
|
||||
|
||||
const centralBankOfHungaryUpdateDateFormat = "2006-01-02 15"
|
||||
const centralBankOfHungaryUpdateDateTimezone = "Europe/Budapest"
|
||||
|
||||
// CentralBankOfHungaryDataSource defines the structure of exchange rates data source of central bank of Hungary
|
||||
type CentralBankOfHungaryDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// CentralBankOfHungaryExchangeRateServiceResponse represents the response data of exchange rate service for central bank of Hungary
|
||||
type CentralBankOfHungaryExchangeRateServiceResponse struct {
|
||||
XMLName xml.Name `xml:"Envelope"`
|
||||
GetCurrentExchangeRatesResult string `xml:"Body>GetCurrentExchangeRatesResponse>GetCurrentExchangeRatesResult"`
|
||||
}
|
||||
|
||||
// CentralBankOfHungaryCurrentExchangeRatesResult represents the current exchange rate result data from central bank of Hungary
|
||||
type CentralBankOfHungaryCurrentExchangeRatesResult struct {
|
||||
XMLName xml.Name `xml:"MNBCurrentExchangeRates"`
|
||||
AllExchangeRates []*CentralBankOfHungaryExchangeRates `xml:"Day"`
|
||||
}
|
||||
|
||||
// CentralBankOfHungaryExchangeRates represents the exchange rates data from Danmarks Nationalbank
|
||||
type CentralBankOfHungaryExchangeRates struct {
|
||||
Date string `xml:"date,attr"`
|
||||
ExchangeRates []*CentralBankOfHungaryExchangeRate `xml:"Rate"`
|
||||
}
|
||||
|
||||
// CentralBankOfHungaryExchangeRate represents the exchange rate data from central bank of Hungary
|
||||
type CentralBankOfHungaryExchangeRate struct {
|
||||
Currency string `xml:"curr,attr"`
|
||||
Unit string `xml:"unit,attr"`
|
||||
Rate string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from central bank of Hungary
|
||||
func (e *CentralBankOfHungaryCurrentExchangeRatesResult) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e.AllExchangeRates) < 1 {
|
||||
log.Errorf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestCentralBankOfHungaryExchangeRate := e.AllExchangeRates[0]
|
||||
|
||||
if len(latestCentralBankOfHungaryExchangeRate.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.AllExchangeRates))
|
||||
|
||||
for i := 0; i < len(latestCentralBankOfHungaryExchangeRate.ExchangeRates); i++ {
|
||||
exchangeRate := latestCentralBankOfHungaryExchangeRate.ExchangeRates[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(centralBankOfHungaryUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", centralBankOfHungaryUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
updateDateTime := latestCentralBankOfHungaryExchangeRate.Date + " 11" // The exchange rates are fixed at 11 am.
|
||||
updateTime, err := time.ParseInLocation(centralBankOfHungaryUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: centralBankOfHungaryDataSource,
|
||||
ReferenceUrl: centralBankOfHungaryExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: centralBankOfHungaryBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from central bank of Hungary
|
||||
func (e *CentralBankOfHungaryExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(strings.ReplaceAll(e.Rate, ",", "."))
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit, err := utils.StringToFloat64(e.Unit)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
if unit <= 0 {
|
||||
log.Warnf(c, "[central_bank_of_hungary_datasource.ToLatestExchangeRate] unit is less or equal zero, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := unit / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the central bank of Hungary exchange rates http requests
|
||||
func (e *CentralBankOfHungaryDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", centralBankOfHungaryExchangeRateServiceUrl, bytes.NewReader([]byte(
|
||||
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRates xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\"/>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>")))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/xml; charset=utf-8")
|
||||
req.Header.Set("SOAPAction", centralBankOfHungaryExchangeRateServiceCurrentExchangeRatesSoapAction)
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the central bank of Hungary data source raw response
|
||||
func (e *CentralBankOfHungaryDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
responseXmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
|
||||
centralBankOfHungaryServiceResponse := &CentralBankOfHungaryExchangeRateServiceResponse{}
|
||||
err := responseXmlDecoder.Decode(centralBankOfHungaryServiceResponse)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_hungary_datasource.Parse] failed to parse service response xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if len(centralBankOfHungaryServiceResponse.GetCurrentExchangeRatesResult) < 1 {
|
||||
log.Errorf(c, "[central_bank_of_hungary_datasource.Parse] exchange rates response is empty")
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
resultXmlDecoder := xml.NewDecoder(strings.NewReader(centralBankOfHungaryServiceResponse.GetCurrentExchangeRatesResult))
|
||||
|
||||
centralBankOfHungaryExchangeRatesResult := &CentralBankOfHungaryCurrentExchangeRatesResult{}
|
||||
err = resultXmlDecoder.Decode(centralBankOfHungaryExchangeRatesResult)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_hungary_datasource.Parse] failed to parse exchange rates response xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := centralBankOfHungaryExchangeRatesResult.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[central_bank_of_hungary_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const centralBankOfHungaryDataSourceMinimumRequiredContent = "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">" +
|
||||
"<s:Body>" +
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">" +
|
||||
"<GetCurrentExchangeRatesResult>" +
|
||||
"<MNBCurrentExchangeRates>" +
|
||||
"<Day date=\"2024-11-15\">" +
|
||||
"<Rate unit=\"100\" curr=\"JPY\">247,46</Rate>" +
|
||||
"<Rate unit=\"1\" curr=\"USD\">384,48</Rate>" +
|
||||
"</Day>" +
|
||||
"</MNBCurrentExchangeRates>" +
|
||||
"</GetCurrentExchangeRatesResult>" +
|
||||
"</GetCurrentExchangeRatesResponse>" +
|
||||
"</s:Body>" +
|
||||
"</s:Envelope>"
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfHungaryDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "HUF", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfHungaryDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731664800), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfHungaryDataSourceMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "0.4041057140547967",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.002600915522263837",
|
||||
})
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_MissingSoapBody(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_MissingGetCurrentExchangeRatesResponse(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_MissingGetCurrentExchangeRatesResult(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_EmptyGetCurrentExchangeRatesResult(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_InvalidGetCurrentExchangeRatesResult(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"1\" curr=\"XXX\">1</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"1\" curr=\"USD\"></Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"1\" curr=\"USD\">null</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"1\" curr=\"USD\">0</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfHungaryDataSource_InvalidUnit(t *testing.T) {
|
||||
dataSource := &CentralBankOfHungaryDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"null\" curr=\"USD\">384,48</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">"+
|
||||
"<s:Body>"+
|
||||
"<GetCurrentExchangeRatesResponse xmlns=\"http://www.mnb.hu/webservices/\" xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\">"+
|
||||
"<GetCurrentExchangeRatesResult>"+
|
||||
"<MNBCurrentExchangeRates>"+
|
||||
"<Day date=\"2024-11-15\">"+
|
||||
"<Rate unit=\"0\" curr=\"USD\">384,48</Rate>"+
|
||||
"</Day>"+
|
||||
"</MNBCurrentExchangeRates>"+
|
||||
"</GetCurrentExchangeRatesResult>"+
|
||||
"</GetCurrentExchangeRatesResponse>"+
|
||||
"</s:Body>"+
|
||||
"</s:Envelope>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const centralBankOfMyanmarExchangeRateUrl = "https://forex.cbm.gov.mm/api/latest"
|
||||
const centralBankOfMyanmarExchangeRateReferenceUrl = "https://forex.cbm.gov.mm/index.php/fxrate"
|
||||
const centralBankOfMyanmarDataSource = "မြန်မာနိုင်ငံတော်ဗဟိုဘဏ်"
|
||||
const centralBankOfMyanmarBaseCurrency = "MMK"
|
||||
|
||||
var centralBankOfMyanmarSpecialCurrencyUnits = map[string]int32{
|
||||
"JPY": 100,
|
||||
"KHR": 100,
|
||||
"IDR": 100,
|
||||
"KRW": 100,
|
||||
"LAK": 100,
|
||||
"VND": 100,
|
||||
}
|
||||
|
||||
// CentralBankOfMyanmarDataSource defines the structure of exchange rates data source of central bank of Myanmar
|
||||
type CentralBankOfMyanmarDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// CentralBankOfMyanmarExchangeRate represents the exchange rate data from central bank of Myanmar
|
||||
type CentralBankOfMyanmarExchangeRate struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
ExchangeRates map[string]string `json:"rates"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from central bank of Myanmar
|
||||
func (e *CentralBankOfMyanmarExchangeRate) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.ExchangeRates))
|
||||
|
||||
for currencyCode, exchangeRate := range e.ExchangeRates {
|
||||
if _, exists := validators.AllCurrencyNames[currencyCode]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
finalExchangeRate := e.BuildLatestExchangeRate(c, currencyCode, exchangeRate)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
updateTime, err := utils.StringToInt64(e.Timestamp)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_myanmar_datasource.ToLatestExchangeRateResponse] failed to parse timestamp, timestamp is %s", e.Timestamp)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: centralBankOfMyanmarDataSource,
|
||||
ReferenceUrl: centralBankOfMyanmarExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime,
|
||||
BaseCurrency: centralBankOfMyanmarBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// BuildLatestExchangeRate returns a data pair according to original data from central bank of Myanmar
|
||||
func (e *CentralBankOfMyanmarExchangeRate) BuildLatestExchangeRate(c core.Context, currencyCode string, exchangeRate string) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(strings.ReplaceAll(exchangeRate, ",", ""))
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[central_bank_of_myanmar_datasource.BuildLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", currencyCode, exchangeRate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[central_bank_of_myanmar_datasource.BuildLatestExchangeRate] rate is invalid, currency is %s, rate is %s", currencyCode, exchangeRate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit, has := centralBankOfMyanmarSpecialCurrencyUnits[currencyCode]
|
||||
|
||||
if !has {
|
||||
unit = 1
|
||||
}
|
||||
|
||||
finalRate := float64(unit) / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: currencyCode,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the central bank of Myanmar exchange rates http requests
|
||||
func (e *CentralBankOfMyanmarDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", centralBankOfMyanmarExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the central bank of Myanmar data source raw response
|
||||
func (e *CentralBankOfMyanmarDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
centralBankOfMyanmarData := &CentralBankOfMyanmarExchangeRate{}
|
||||
err := json.Unmarshal(content, centralBankOfMyanmarData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_myanmar_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := centralBankOfMyanmarData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[central_bank_of_myanmar_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const centralBankOfMyanmarMinimumRequiredContent = "{\n" +
|
||||
" \"timestamp\": \"1731571200\",\n" +
|
||||
" \"rates\": {\n" +
|
||||
" \"USD\": \"2,100.0\",\n" +
|
||||
" \"JPY\": \"1,347.6\"\n" +
|
||||
" }\n" +
|
||||
"}"
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfMyanmarMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "MMK", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfMyanmarMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731571200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfMyanmarMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "0.07420599584446423",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.0004761904761904762",
|
||||
})
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_EmptyData(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("{}"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_EmptyExchangeRatesData(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\"\n"+
|
||||
"}"))
|
||||
|
||||
_, err = dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\",\n"+
|
||||
" \"rates\": {\n"+
|
||||
" }\n"+
|
||||
"}"))
|
||||
assert.Nil(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\",\n"+
|
||||
" \"rates\": {\n"+
|
||||
" \"XXX\": \"1\"\n"+
|
||||
" }\n"+
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfMyanmarDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &CentralBankOfMyanmarDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\",\n"+
|
||||
" \"rates\": {\n"+
|
||||
" \"USD\": null\n"+
|
||||
" }\n"+
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("{\n"+
|
||||
" \"timestamp\": \"1731571200\",\n"+
|
||||
" \"rates\": {\n"+
|
||||
" \"USD\": \"0\"\n"+
|
||||
" }\n"+
|
||||
"}"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const centralBankOfUzbekistanExchangeRateUrl = "https://cbu.uz/ru/arkhiv-kursov-valyut/json/"
|
||||
const centralBankOfUzbekistanExchangeRateReferenceUrl = "https://cbu.uz/en/arkhiv-kursov-valyut/"
|
||||
const centralBankOfUzbekistanDataSource = "O‘zbekiston Respublikasi Markaziy banki"
|
||||
const centralBankOfUzbekistanBaseCurrency = "UZS"
|
||||
|
||||
const centralBankOfUzbekistanUpdateDateFormat = "02.01.2006"
|
||||
const centralBankOfUzbekistanUpdateDateTimezone = "Asia/Samarkand"
|
||||
|
||||
// CentralBankOfUzbekistanDataSource defines the structure of exchange rates data source of the central bank of the Republic of Uzbekistan
|
||||
type CentralBankOfUzbekistanDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// CentralBankOfUzbekistanExchangeRates represents the exchange rates data from the central bank of the Republic of Uzbekistan
|
||||
type CentralBankOfUzbekistanExchangeRates []*CentralBankOfUzbekistanExchangeRate
|
||||
|
||||
// CentralBankOfUzbekistanExchangeRate represents the exchange rate data from the central bank of the Republic of Uzbekistan
|
||||
type CentralBankOfUzbekistanExchangeRate struct {
|
||||
Currency string `json:"Ccy"`
|
||||
Unit string `json:"Nominal"`
|
||||
Rate string `json:"Rate"`
|
||||
Date string `json:"Date"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the central bank of the Republic of Uzbekistan
|
||||
func (e CentralBankOfUzbekistanExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e) < 1 {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(centralBankOfUzbekistanUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", danmarksNationalbankDataUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e))
|
||||
latestUpdateTime := int64(0)
|
||||
|
||||
for i := 0; i < len(e); i++ {
|
||||
exchangeRate := e[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTime, err := time.ParseInLocation(centralBankOfUzbekistanUpdateDateFormat, exchangeRate.Date, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
if updateTime.Unix() > latestUpdateTime {
|
||||
latestUpdateTime = updateTime.Unix()
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: centralBankOfUzbekistanDataSource,
|
||||
ReferenceUrl: centralBankOfUzbekistanExchangeRateReferenceUrl,
|
||||
UpdateTime: latestUpdateTime,
|
||||
BaseCurrency: centralBankOfUzbekistanBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from the central bank of the Republic of Uzbekistan
|
||||
func (e *CentralBankOfUzbekistanExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit, err := utils.StringToFloat64(e.Unit)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
if unit <= 0 {
|
||||
log.Warnf(c, "[central_bank_of_uzbekistan_datasource.ToLatestExchangeRate] unit is less or equal zero, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 1000 * unit / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the the central bank of the Republic of Uzbekistan exchange rates http requests
|
||||
func (e *CentralBankOfUzbekistanDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", centralBankOfUzbekistanExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the the central bank of the Republic of Uzbekistan data source raw response
|
||||
func (e *CentralBankOfUzbekistanDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
centralBankOfUzbekistanData := &CentralBankOfUzbekistanExchangeRates{}
|
||||
err := json.Unmarshal(content, centralBankOfUzbekistanData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := centralBankOfUzbekistanData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[central_bank_of_uzbekistan_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const centralBankOfUzbekistanMinimumRequiredContent = "[\n" +
|
||||
" {\n" +
|
||||
" \"Ccy\": \"USD\",\n" +
|
||||
" \"Nominal\": \"1\",\n" +
|
||||
" \"Rate\": \"12800.13\",\n" +
|
||||
" \"Date\": \"15.11.2024\"\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"Ccy\": \"VND\",\n" +
|
||||
" \"Nominal\": \"10\",\n" +
|
||||
" \"Rate\": \"5.04\",\n" +
|
||||
" \"Date\": \"15.11.2024\"\n" +
|
||||
" }\n" +
|
||||
"]"
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfUzbekistanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "UZS", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfUzbekistanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731610800), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(centralBankOfUzbekistanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.07812420655102723",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "VND",
|
||||
Rate: "1984.126984126984",
|
||||
})
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_EmptyData(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("[]"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"XXX\",\n"+
|
||||
" \"Nominal\": \"1\",\n"+
|
||||
" \"Rate\": \"1\",\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_InvalidNominal(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"USD\",\n"+
|
||||
" \"Nominal\": null,\n"+
|
||||
" \"Rate\": \"12800.13\",\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"USD\",\n"+
|
||||
" \"Nominal\": \"0\",\n"+
|
||||
" \"Rate\": \"12800.13\",\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCentralBankOfUzbekistanDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &CentralBankOfUzbekistanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"USD\",\n"+
|
||||
" \"Nominal\": \"1\",\n"+
|
||||
" \"Rate\": null,\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"Ccy\": \"USD\",\n"+
|
||||
" \"Nominal\": \"1\",\n"+
|
||||
" \"Rate\": \"0\",\n"+
|
||||
" \"Date\": \"15.11.2024\"\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package exchangerates
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,9 +28,21 @@ type CzechNationalBankDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the czech nation bank data source urls
|
||||
func (e *CzechNationalBankDataSource) GetRequestUrls() []string {
|
||||
return []string{czechNationalBankMonthlyOtherExchangeRateUrl, czechNationalBankDailyExchangeRateUrl}
|
||||
// BuildRequests returns the Czech National Bank exchange rates http requests
|
||||
func (e *CzechNationalBankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
monthlyReq, err := http.NewRequest("GET", czechNationalBankMonthlyOtherExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dailyReq, err := http.NewRequest("GET", czechNationalBankDailyExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{monthlyReq, dailyReq}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the czech nation bank data source raw response
|
||||
@@ -140,6 +153,11 @@ func (e *CzechNationalBankDataSource) parseExchangeRate(c core.Context, line str
|
||||
return nil
|
||||
}
|
||||
|
||||
if amount <= 0 {
|
||||
log.Warnf(c, "[czech_national_bank_datasource.parseExchangeRate] amount is invalid, line is %s", line)
|
||||
return nil
|
||||
}
|
||||
|
||||
rate, err := utils.StringToFloat64(items[rateColumnIndex])
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -23,6 +23,15 @@ func TestCzechNationalBankDataSource_StandardDataExtractBaseCurrency(t *testing.
|
||||
assert.Equal(t, "CZK", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(czechNationalBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617280200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -64,6 +73,16 @@ func TestCzechNationalBankDataSource_OnlyHeaderAndTitle(t *testing.T) {
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_MissingHeader(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("Country|Currency|Amount|Code|Rate\n"+
|
||||
"China|renminbi|1|CNY|3.379\n"+
|
||||
"USA|dollar|1|USD|22.206\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_TitleMissingCode(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -75,6 +94,17 @@ func TestCzechNationalBankDataSource_TitleMissingCode(t *testing.T) {
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_TitleMissingAmount(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Code|Rate\n"+
|
||||
"China|renminbi|CNY|3.379\n"+
|
||||
"USA|dollar|USD|22.206\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_TitleMissingRate(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -86,6 +116,17 @@ func TestCzechNationalBankDataSource_TitleMissingRate(t *testing.T) {
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_MissingDataItem(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Amount|Code|Rate\n"+
|
||||
"USA|dollar|1|USD\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -97,6 +138,23 @@ func TestCzechNationalBankDataSource_InvalidCurrency(t *testing.T) {
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_InvalidAmount(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Amount|Code|Rate\n"+
|
||||
"USA|dollar|null|USD|22.206\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Amount|Code|Rate\n"+
|
||||
"USA|dollar|0|USD|22.206\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestCzechNationalBankDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &CzechNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -117,4 +175,10 @@ func TestCzechNationalBankDataSource_InvalidRate(t *testing.T) {
|
||||
"USA|dollar|1|USD|null\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("01 Apr 2021 #64\n"+
|
||||
"Country|Currency|Amount|Code|Rate\n"+
|
||||
"USA|dollar|1|USD|0\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const danmarksNationalbankExchangeRateUrl = "https://www.nationalbanken.dk/api/currencyratesxml?lang=en"
|
||||
const danmarksNationalbankExchangeRateReferenceUrl = "https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates"
|
||||
const danmarksNationalbankDataSource = "Danmarks Nationalbank"
|
||||
|
||||
const danmarksNationalbankDataUpdateDateFormat = "2006-01-02 15"
|
||||
const danmarksNationalbankDataUpdateDateTimezone = "Europe/Copenhagen"
|
||||
|
||||
// DanmarksNationalbankDataSource defines the structure of exchange rates data source of Danmarks Nationalbank
|
||||
type DanmarksNationalbankDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// DanmarksNationalbankExchangeRateData represents the whole data from Danmarks Nationalbank
|
||||
type DanmarksNationalbankExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"exchangerates"`
|
||||
DailyExchangeRates []*DanmarksNationalbankDailyExchangeRates `xml:"dailyrates"`
|
||||
BaseCurrency string `xml:"refcur,attr"`
|
||||
}
|
||||
|
||||
// DanmarksNationalbankDailyExchangeRates represents the exchange rates data from Danmarks Nationalbank
|
||||
type DanmarksNationalbankDailyExchangeRates struct {
|
||||
Date string `xml:"id,attr"`
|
||||
ExchangeRates []*DanmarksNationalbankExchangeRate `xml:"currency"`
|
||||
}
|
||||
|
||||
// DanmarksNationalbankExchangeRate represents the exchange rate data from Danmarks Nationalbank
|
||||
type DanmarksNationalbankExchangeRate struct {
|
||||
Currency string `xml:"code,attr"`
|
||||
Rate string `xml:"rate,attr"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from Danmarks Nationalbank
|
||||
func (e *DanmarksNationalbankExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e.DailyExchangeRates) < 1 {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRateResponse] daily exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestDanmarksNationalbankExchangeRate := e.DailyExchangeRates[0]
|
||||
|
||||
if len(latestDanmarksNationalbankExchangeRate.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(latestDanmarksNationalbankExchangeRate.ExchangeRates))
|
||||
|
||||
for i := 0; i < len(latestDanmarksNationalbankExchangeRate.ExchangeRates); i++ {
|
||||
exchangeRate := latestDanmarksNationalbankExchangeRate.ExchangeRates[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(danmarksNationalbankDataUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", danmarksNationalbankDataUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
updateDateTime := latestDanmarksNationalbankExchangeRate.Date + " 16" // ECB publishes the reference rates determined at the concertation at 16:00 and shortly after Danmarks Nationalbank publishes the prices in Danish kroner
|
||||
updateTime, err := time.ParseInLocation(danmarksNationalbankDataUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: danmarksNationalbankDataSource,
|
||||
ReferenceUrl: danmarksNationalbankExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: e.BaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from Danmarks Nationalbank
|
||||
func (e *DanmarksNationalbankExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[danmarks_national_bank_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 100 / rate // the latest exchange rates listed as the price in Danish kroner for 100 units of foreign currency
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the Danmarks Nationalbank exchange rates http requests
|
||||
func (e *DanmarksNationalbankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", danmarksNationalbankExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the Danmarks Nationalbank data source raw response
|
||||
func (e *DanmarksNationalbankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
danmarksNationalbankData := &DanmarksNationalbankExchangeRateData{}
|
||||
err := xmlDecoder.Decode(danmarksNationalbankData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := danmarksNationalbankData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[danmarks_national_bank_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const danmarksNationalbankMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
"<exchangerates refcur=\"DKK\">\n" +
|
||||
" <dailyrates id=\"2024-11-14\">\n" +
|
||||
" <currency code=\"CNY\" rate=\"97.81\" />\n" +
|
||||
" <currency code=\"USD\" rate=\"708.18\" />\n" +
|
||||
" </dailyrates>\n" +
|
||||
"</exchangerates>"
|
||||
|
||||
func TestDanmarksNationalbankDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(danmarksNationalbankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "DKK", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(danmarksNationalbankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731596400), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(danmarksNationalbankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.1412070377587619",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "CNY",
|
||||
Rate: "1.022390348635109",
|
||||
})
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_EmptyExchangeRatesContent(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
"</exchangerates>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_EmptyDailyRatesContent(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
"<dailyrates id=\"2024-11-14\">"+
|
||||
"</dailyrates>"+
|
||||
"</exchangerates>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
" <dailyrates id=\"2024-11-14\">\n"+
|
||||
" <currency code=\"XXX\" desc=\"XXX\" rate=\"1\" />\n"+
|
||||
" </dailyrates>\n"+
|
||||
"</exchangerates>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
" <dailyrates id=\"2024-11-14\">\n"+
|
||||
" <currency code=\"USD\" rate=\"\" />\n"+
|
||||
" </dailyrates>\n"+
|
||||
"</exchangerates>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestDanmarksNationalbankDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &DanmarksNationalbankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
" <dailyrates id=\"2024-11-14\">\n"+
|
||||
" <currency code=\"USD\" rate=\"null\" />\n"+
|
||||
" </dailyrates>\n"+
|
||||
"</exchangerates>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<exchangerates refcur=\"DKK\">"+
|
||||
" <dailyrates id=\"2024-11-14\">\n"+
|
||||
" <currency code=\"USD\" rate=\"0\" />\n"+
|
||||
" </dailyrates>\n"+
|
||||
"</exchangerates>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
@@ -107,15 +111,24 @@ func (e *EuroCentralBankExchangeRate) ToLatestExchangeRate() *models.LatestExcha
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the euro central bank data source urls
|
||||
func (e *EuroCentralBankDataSource) GetRequestUrls() []string {
|
||||
return []string{euroCentralBankExchangeRateUrl}
|
||||
// BuildRequests returns the euro central bank exchange rates http requests
|
||||
func (e *EuroCentralBankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", euroCentralBankExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the euro central bank data source raw response
|
||||
func (e *EuroCentralBankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
euroCentralBankData := &EuroCentralBankExchangeRateData{}
|
||||
err := xml.Unmarshal(content, euroCentralBankData)
|
||||
err := xmlDecoder.Decode(euroCentralBankData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[euro_central_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
|
||||
@@ -28,6 +28,15 @@ func TestEuroCentralBankDataSource_StandardDataExtractBaseCurrency(t *testing.T)
|
||||
assert.Equal(t, "EUR", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestEuroCentralBankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &EuroCentralBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(euroCentralBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617285600), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestEuroCentralBankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &EuroCentralBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
// ExchangeRatesDataSource defines the structure of exchange rates data source
|
||||
type ExchangeRatesDataSource interface {
|
||||
// GetRequestUrl returns the data source urls
|
||||
GetRequestUrls() []string
|
||||
// BuildRequests returns the http requests
|
||||
BuildRequests() ([]*http.Request, error)
|
||||
|
||||
// Parse returns the common response entity according to the data source raw response
|
||||
Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error)
|
||||
|
||||
@@ -17,21 +17,51 @@ var (
|
||||
|
||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
||||
Container.Current = &EuroCentralBankDataSource{}
|
||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.Current = &ReserveBankOfAustraliaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
Container.Current = &BankOfCanadaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.Current = &ReserveBankOfAustraliaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||
Container.Current = &CzechNationalBankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
|
||||
Container.Current = &DanmarksNationalbankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
||||
Container.Current = &EuroCentralBankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
|
||||
Container.Current = &NationalBankOfGeorgiaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
|
||||
Container.Current = &CentralBankOfHungaryDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
|
||||
Container.Current = &BankOfIsraelDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
||||
Container.Current = &CentralBankOfMyanmarDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
|
||||
Container.Current = &NorgesBankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
|
||||
Container.Current = &NationalBankOfPolandDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
|
||||
Container.Current = &NationalBankOfRomaniaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
|
||||
Container.Current = &BankOfRussiaDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
|
||||
Container.Current = &SwissNationalBankDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
|
||||
Container.Current = &CentralBankOfUzbekistanDataSource{}
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
|
||||
Container.Current = &InternationalMonetaryFundDataSource{}
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -71,9 +72,17 @@ func init() {
|
||||
internationalMonetaryFundCurrencyNameCodeMap["Uruguayan peso"] = "UYU"
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the international monetary fund data source urls
|
||||
func (e *InternationalMonetaryFundDataSource) GetRequestUrls() []string {
|
||||
return []string{internationalMonetaryFundExchangeRateUrl}
|
||||
// BuildRequests returns the international monetary fund exchange rates http requests
|
||||
func (e *InternationalMonetaryFundDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", internationalMonetaryFundExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "") // Do not set custom user agent
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the international monetary fund data source raw response
|
||||
|
||||
@@ -26,6 +26,15 @@ func TestInternationalMonetaryFundDataSource_StandardDataExtractBaseCurrency(t *
|
||||
assert.Equal(t, "USD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(internationalMonetaryFundMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1724857200), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -95,6 +104,20 @@ func TestInternationalMonetaryFundDataSource_MissingDefaultCurrencyData(t *testi
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_DefaultCurrencyDataInvalid(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t0.1040520000\t0.1039250000\t0.1040370000\t0.1040850000\t0.1040570000\n"+
|
||||
"U.S. dollar\t0\t0\t0\t0\t0\n"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -110,6 +133,41 @@ func TestInternationalMonetaryFundDataSource_InvalidCurrency(t *testing.T) {
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\tnull\tnull\tnull\tnull\tnull\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t0\t0\t0\t0\t0\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("SDRs per Currency unit and Currency units per SDR (1)\n"+
|
||||
"last five days\n"+
|
||||
"SDRs per Currency unit (2)\n"+
|
||||
"\n"+
|
||||
"Currency\tAugust 28, 2024\tAugust 27, 2024\tAugust 26, 2024\tAugust 23, 2024\tAugust 22, 2024\n"+
|
||||
"Chinese yuan\t\t\t\t\t\n"+
|
||||
"U.S. dollar\t0.7417320000\t0.7410250000\t0.7408270000\t0.7429280000\t0.7423020000\n"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 1)
|
||||
}
|
||||
|
||||
func TestInternationalMonetaryFundDataSource_LatestDateNotHasRate(t *testing.T) {
|
||||
dataSource := &InternationalMonetaryFundDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const nationalBankOfGeorgiaExchangeRateUrl = "https://nbg.gov.ge/gw/api/ct/monetarypolicy/currencies/en/json"
|
||||
const nationalBankOfGeorgiaExchangeRateReferenceUrl = "https://nbg.gov.ge/en/monetary-policy/currency"
|
||||
const nationalBankOfGeorgiaDataSource = "საქართველოს ეროვნული ბანკი"
|
||||
const nationalBankOfGeorgiaBaseCurrency = "GEL"
|
||||
|
||||
const nationalBankOfGeorgiaUpdateDateFormat = "2006-01-02T15:04:05.999Z"
|
||||
|
||||
// NationalBankOfGeorgiaDataSource defines the structure of exchange rates data source of national bank of Georgia
|
||||
type NationalBankOfGeorgiaDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// NationalBankOfGeorgiaExchangeRates represents the exchange rates data from national bank of Georgia
|
||||
type NationalBankOfGeorgiaExchangeRates struct {
|
||||
Date string `json:"date"`
|
||||
ExchangeRates []*NationalBankOfGeorgiaExchangeRate `json:"currencies"`
|
||||
}
|
||||
|
||||
// NationalBankOfGeorgiaExchangeRate represents the exchange rate data from national bank of Georgia
|
||||
type NationalBankOfGeorgiaExchangeRate struct {
|
||||
Currency string `json:"code"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Rate float64 `json:"rate"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from national bank of Georgia
|
||||
func (e *NationalBankOfGeorgiaExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if len(e.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.ExchangeRates))
|
||||
latestUpdateTime := int64(0)
|
||||
|
||||
for i := 0; i < len(e.ExchangeRates); i++ {
|
||||
exchangeRate := e.ExchangeRates[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTime, err := time.Parse(nationalBankOfGeorgiaUpdateDateFormat, exchangeRate.Date)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
if updateTime.Unix() > latestUpdateTime {
|
||||
latestUpdateTime = updateTime.Unix()
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: nationalBankOfGeorgiaDataSource,
|
||||
ReferenceUrl: nationalBankOfGeorgiaExchangeRateReferenceUrl,
|
||||
UpdateTime: latestUpdateTime,
|
||||
BaseCurrency: nationalBankOfGeorgiaBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from national bank of Georgia
|
||||
func (e *NationalBankOfGeorgiaExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
if e.Rate <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_georgia_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %f", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if e.Quantity <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_georgia_datasource.ToLatestExchangeRate] quantity is invalid, currency is %s, quantity is %f", e.Currency, e.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := e.Quantity / e.Rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the national bank of Georgia exchange rates http requests
|
||||
func (e *NationalBankOfGeorgiaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", nationalBankOfGeorgiaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the national bank of Georgia data source raw response
|
||||
func (e *NationalBankOfGeorgiaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
nationalBankOfGeorgiaData := &[]*NationalBankOfGeorgiaExchangeRates{}
|
||||
err := json.Unmarshal(content, nationalBankOfGeorgiaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if nationalBankOfGeorgiaData == nil || len(*nationalBankOfGeorgiaData) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := (*nationalBankOfGeorgiaData)[0].ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[national_bank_of_georgia_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const nationalBankOfGeorgiaMinimumRequiredContent = "[\n" +
|
||||
" {\n" +
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n" +
|
||||
" \"currencies\": [\n" +
|
||||
" {\n" +
|
||||
" \"code\": \"JPY\",\n" +
|
||||
" \"quantity\": 100,\n" +
|
||||
" \"rate\": 1.7589,\n" +
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n" +
|
||||
" },\n" +
|
||||
" {\n" +
|
||||
" \"code\": \"USD\",\n" +
|
||||
" \"quantity\": 1,\n" +
|
||||
" \"rate\": 2.7311,\n" +
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" }\n" +
|
||||
"]"
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfGeorgiaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "GEL", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfGeorgiaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731690071), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfGeorgiaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "56.853715390300756",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.366152832192157",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_EmptyData(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("[]"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_EmptyExchangeRatesData(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("[{}]"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
|
||||
_, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"XXX\",\n"+
|
||||
" \"quantity\": 1,\n"+
|
||||
" \"rate\": 1,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_InvalidQuantity(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"USD\",\n"+
|
||||
" \"quantity\": null,\n"+
|
||||
" \"rate\": 2.7311,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"USD\",\n"+
|
||||
" \"quantity\": 0,\n"+
|
||||
" \"rate\": 2.7311,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfGeorgiaDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &NationalBankOfGeorgiaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"USD\",\n"+
|
||||
" \"quantity\": 1,\n"+
|
||||
" \"rate\": null,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("[\n"+
|
||||
" {\n"+
|
||||
" \"date\": \"2024-11-16T00:00:00.000Z\",\n"+
|
||||
" \"currencies\": [\n"+
|
||||
" {\n"+
|
||||
" \"code\": \"USD\",\n"+
|
||||
" \"quantity\": 1,\n"+
|
||||
" \"rate\": 0,\n"+
|
||||
" \"date\": \"2024-11-15T17:01:11.702Z\"\n"+
|
||||
" }\n"+
|
||||
" ]\n"+
|
||||
" }\n"+
|
||||
"]"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
@@ -125,13 +126,30 @@ func (e *NationalBankOfPolandDataSource) GetRequestUrls() []string {
|
||||
return []string{nationalBankOfPolandInconvertibleCurrencyExchangeRateUrl, nationalBankOfPolandDailyExchangeRateUrl}
|
||||
}
|
||||
|
||||
// BuildRequests returns the national bank of Poland exchange rates http requests
|
||||
func (e *NationalBankOfPolandDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
inconvertibleCurrencyReq, err := http.NewRequest("GET", nationalBankOfPolandInconvertibleCurrencyExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dailyReq, err := http.NewRequest("GET", nationalBankOfPolandDailyExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{inconvertibleCurrencyReq, dailyReq}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the National Bank of Poland data source raw response
|
||||
func (e *NationalBankOfPolandDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
nationalBankOfPolandData := &NationalBankOfPolandExchangeRateData{}
|
||||
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
err := xmlDecoder.Decode(&nationalBankOfPolandData)
|
||||
|
||||
nationalBankOfPolandData := &NationalBankOfPolandExchangeRateData{}
|
||||
err := xmlDecoder.Decode(nationalBankOfPolandData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_poland_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
|
||||
@@ -35,6 +35,15 @@ func TestNationalBankOfPolandDataSource_StandardDataExtractBaseCurrency(t *testi
|
||||
assert.Equal(t, "PLN", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNationalBankOfPolandDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NationalBankOfPolandDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfPolandMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1709118900), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNationalBankOfPolandDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NationalBankOfPolandDataSource{}
|
||||
context := core.NewNullContext()
|
||||
@@ -162,4 +171,19 @@ func TestNationalBankOfPolandDataSource_InvalidRate(t *testing.T) {
|
||||
"</ArrayOfExchangeRatesTable>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"+
|
||||
"<ArrayOfExchangeRatesTable xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">\n"+
|
||||
" <ExchangeRatesTable>\n"+
|
||||
" <EffectiveDate>2024-02-28</EffectiveDate>\n"+
|
||||
" <Rates>\n"+
|
||||
" <Rate>\n"+
|
||||
" <Code>USD</Code>\n"+
|
||||
" <Mid>0</Mid>\n"+
|
||||
" </Rate>\n"+
|
||||
" </Rates>\n"+
|
||||
" </ExchangeRatesTable>\n"+
|
||||
"</ArrayOfExchangeRatesTable>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const nationalBankOfRomaniaExchangeRateUrl = "https://www.bnr.ro/nbrfxrates.xml"
|
||||
const nationalBankOfRomaniaExchangeRateReferenceUrl = "https://www.bnr.ro/Exchange-rates-1224.aspx"
|
||||
const nationalBankOfRomaniaDataSource = "Banca Naţională a României"
|
||||
|
||||
const nationalBankOfRomaniaUpdateDateFormat = "2006-01-02 15"
|
||||
const nationalBankOfRomaniaUpdateDateTimezone = "Europe/Bucharest"
|
||||
|
||||
// NationalBankOfRomaniaDataSource defines the structure of exchange rates data source of national bank of Romania
|
||||
type NationalBankOfRomaniaDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRateData represents the whole data from national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"DataSet"`
|
||||
Header *NationalBankOfRomaniaExchangeRateDataHeader `xml:"Header"`
|
||||
Body *NationalBankOfRomaniaExchangeRateDataBody `xml:"Body"`
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRateDataHeader represents the header for exchange rates data of national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRateDataHeader struct {
|
||||
PublishingDate string `xml:"PublishingDate"`
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRateDataBody represents the body for exchange rates data of national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRateDataBody struct {
|
||||
OrigCurrency string `xml:"OrigCurrency"`
|
||||
AllExchangeRates []*NationalBankOfRomaniaExchangeRates `xml:"Cube"`
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRates represents the exchange rates data from national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRates struct {
|
||||
Date string `xml:"date,attr"`
|
||||
ExchangeRates []*NationalBankOfRomaniaExchangeRate `xml:"Rate"`
|
||||
}
|
||||
|
||||
// NationalBankOfRomaniaExchangeRate represents the exchange rate data from national bank of Romania
|
||||
type NationalBankOfRomaniaExchangeRate struct {
|
||||
Currency string `xml:"currency,attr"`
|
||||
Multiplier string `xml:"multiplier,attr"`
|
||||
Rate string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from national bank of Romania
|
||||
func (e *NationalBankOfRomaniaExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e.Header == nil || e.Body == nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] header or body is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(e.Body.AllExchangeRates) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestNationalBankOfRomaniaExchangeRate := e.Body.AllExchangeRates[0]
|
||||
|
||||
if len(latestNationalBankOfRomaniaExchangeRate.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(latestNationalBankOfRomaniaExchangeRate.ExchangeRates))
|
||||
|
||||
for i := 0; i < len(latestNationalBankOfRomaniaExchangeRate.ExchangeRates); i++ {
|
||||
exchangeRate := latestNationalBankOfRomaniaExchangeRate.ExchangeRates[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(nationalBankOfRomaniaUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", nationalBankOfRomaniaUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
updateDateTime := e.Header.PublishingDate + " 13" // The data are updated in real time, shortly after 13:00, every banking day.
|
||||
updateTime, err := time.ParseInLocation(nationalBankOfRomaniaUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: nationalBankOfRomaniaDataSource,
|
||||
ReferenceUrl: nationalBankOfRomaniaExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: e.Body.OrigCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from national bank of Romania
|
||||
func (e *NationalBankOfRomaniaExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit := float64(1)
|
||||
|
||||
if e.Multiplier != "" {
|
||||
unit, err = utils.StringToFloat64(e.Multiplier)
|
||||
|
||||
if err != nil || unit <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_romania_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit is %s", e.Currency, e.Multiplier)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
finalRate := unit / rate
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the national bank of Romania exchange rates http requests
|
||||
func (e *NationalBankOfRomaniaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", nationalBankOfRomaniaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the national bank of Romania data source raw response
|
||||
func (e *NationalBankOfRomaniaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
nationalBankOfRomaniaData := &NationalBankOfRomaniaExchangeRateData{}
|
||||
err := xmlDecoder.Decode(nationalBankOfRomaniaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := nationalBankOfRomaniaData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[national_bank_of_romania_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const nationalBankOfRomaniaMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">\n" +
|
||||
" <Header>\n" +
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n" +
|
||||
" </Header>\n" +
|
||||
" <Body>\n" +
|
||||
" <OrigCurrency>RON</OrigCurrency>\n" +
|
||||
" <Cube date=\"2024-11-15\">\n" +
|
||||
" <Rate currency=\"JPY\" multiplier=\"100\">3.0303</Rate>\n" +
|
||||
" <Rate currency=\"USD\">4.7057</Rate>\n" +
|
||||
" </Cube>\n" +
|
||||
" </Body>\n" +
|
||||
"</DataSet>"
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "RON", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731668400), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(nationalBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "33.000033000033",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.21250823469409438",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_EmptyExchangeRatesDataset(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
"</DataSet>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_NoDailyRatesHeader(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
"</DataSet>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_NoDailyRatesBody(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
"</DataSet>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_NoDailyRatesCube(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"XXX\">1</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"USD\"></Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"USD\">null</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"USD\">0</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfRomaniaDataSource_InvalidMultiplier(t *testing.T) {
|
||||
dataSource := &NationalBankOfRomaniaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"JPY\" multiplier=\"null\">3.0303</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<DataSet xmlns=\"http://www.bnr.ro/xsd\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.bnr.ro/xsd nbrfxrates.xsd\">"+
|
||||
" <Header>\n"+
|
||||
" <PublishingDate>2024-11-15</PublishingDate>\n"+
|
||||
" </Header>\n"+
|
||||
" <Body>\n"+
|
||||
" <OrigCurrency>RON</OrigCurrency>\n"+
|
||||
" <Cube date=\"2024-11-15\">\n"+
|
||||
" <Rate currency=\"JPY\" multiplier=\"0\">3.0303</Rate>\n"+
|
||||
" </Cube>\n"+
|
||||
" </Body>\n"+
|
||||
"</DataSet>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const norgesBankExchangeRateUrl = "https://data.norges-bank.no/api/data/EXR/B..NOK.SP?format=sdmx-compact-2.1&lastNObservations=1"
|
||||
const norgesBankExchangeRateReferenceUrl = "https://www.norges-bank.no/en/topics/Statistics/exchange_rates/"
|
||||
const norgesBankDataSource = "Norges Bank"
|
||||
const norgesBankBaseCurrency = "NOK"
|
||||
|
||||
const norgesBankUpdateDateFormat = "2006-01-02 15"
|
||||
const norgesBankUpdateDateTimezone = "Europe/Oslo"
|
||||
|
||||
// NorgesBankDataSource defines the structure of exchange rates data source of Norges Bank
|
||||
type NorgesBankDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// NorgesBankExchangeRateData represents the whole data from Norges Bank
|
||||
type NorgesBankExchangeRateData struct {
|
||||
XMLName xml.Name `xml:"StructureSpecificData"`
|
||||
DataSet *NorgesBankExchangeRateDataSet `xml:"DataSet"`
|
||||
}
|
||||
|
||||
// NorgesBankExchangeRateDataSet represents the dataset for exchange rates data of Norges Bank
|
||||
type NorgesBankExchangeRateDataSet struct {
|
||||
ExchangeRates []*NorgesBankExchangeRate `xml:"Series"`
|
||||
}
|
||||
|
||||
// NorgesBankExchangeRate represents the exchange rate data from Norges Bank
|
||||
type NorgesBankExchangeRate struct {
|
||||
BaseCurrency string `xml:"BASE_CUR,attr"`
|
||||
TargetCurrency string `xml:"QUOTE_CUR,attr"`
|
||||
UnitExponent string `xml:"UNIT_MULT,attr"`
|
||||
Observations []*NorgesBankExchangeRateObservation `xml:"Obs"`
|
||||
}
|
||||
|
||||
// NorgesBankExchangeRateObservation represents the observation data of exchange rate data from Norges Bank
|
||||
type NorgesBankExchangeRateObservation struct {
|
||||
Date string `xml:"TIME_PERIOD,attr"`
|
||||
Rate string `xml:"OBS_VALUE,attr"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from Norges Bank
|
||||
func (e *NorgesBankExchangeRateData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e.DataSet == nil || len(e.DataSet.ExchangeRates) < 1 {
|
||||
log.Errorf(c, "[norges_bank_datasource.ToLatestExchangeRateResponse] all exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(norgesBankUpdateDateTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[norges_bank_datasource.ToLatestExchangeRateResponse] failed to get timezone, timezone name is %s", norgesBankUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.DataSet.ExchangeRates))
|
||||
latestUpdateTime := int64(0)
|
||||
|
||||
for i := 0; i < len(e.DataSet.ExchangeRates); i++ {
|
||||
exchangeRate := e.DataSet.ExchangeRates[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.BaseCurrency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if exchangeRate.TargetCurrency != norgesBankBaseCurrency {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(exchangeRate.Observations) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
updateDateTime := exchangeRate.Observations[0].Date + " 16" // Publication time of daily exchange rates is approximately 16:00 CET.
|
||||
updateTime, err := time.ParseInLocation(norgesBankUpdateDateFormat, updateDateTime, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[norges_bank_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Observations[0].Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
if updateTime.Unix() > latestUpdateTime {
|
||||
latestUpdateTime = updateTime.Unix()
|
||||
}
|
||||
|
||||
finalExchangeRate := exchangeRate.ToLatestExchangeRate(c, exchangeRate.Observations[0].Rate)
|
||||
|
||||
if finalExchangeRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalExchangeRate)
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: norgesBankDataSource,
|
||||
ReferenceUrl: norgesBankExchangeRateReferenceUrl,
|
||||
UpdateTime: latestUpdateTime,
|
||||
BaseCurrency: norgesBankBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from Norges Bank
|
||||
func (e *NorgesBankExchangeRate) ToLatestExchangeRate(c core.Context, exchangeRate string) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(exchangeRate)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[norges_bank_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.BaseCurrency, exchangeRate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[norges_bank_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.BaseCurrency, exchangeRate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unitExponent, err := utils.StringToInt(e.UnitExponent)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[norges_bank_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit exponent is %s", e.BaseCurrency, e.UnitExponent)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 1 / rate
|
||||
|
||||
if unitExponent > 0 {
|
||||
finalRate = finalRate / math.Pow10(-unitExponent)
|
||||
} else if unitExponent < 0 {
|
||||
log.Warnf(c, "[norges_bank_datasource.ToLatestExchangeRate] unit exponent is less than zero, currency is %s, unit is %s", e.BaseCurrency, e.UnitExponent)
|
||||
return nil
|
||||
}
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.BaseCurrency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the Norges Bank exchange rates http requests
|
||||
func (e *NorgesBankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", norgesBankExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the Norges Bank data source raw response
|
||||
func (e *NorgesBankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
norgesBankData := &NorgesBankExchangeRateData{}
|
||||
err := xmlDecoder.Decode(norgesBankData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[norges_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := norgesBankData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[norges_bank_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const norgesBankOfRomaniaMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">\n" +
|
||||
" <message:DataSet>\n" +
|
||||
" <Series BASE_CUR=\"JPY\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"2\">\n" +
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"7.1179\" />\n" +
|
||||
" </Series>\n" +
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n" +
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n" +
|
||||
" </Series>\n" +
|
||||
" </message:DataSet>\n" +
|
||||
"</message:StructureSpecificData>"
|
||||
|
||||
func TestNorgesBankDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(norgesBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "NOK", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(norgesBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731682800), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(norgesBankOfRomaniaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "14.049087511766112",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.09046089827671988",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_MissingExchangeRatesDataset(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_EmptyExchangeRatesDataset(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_EmptyExchangeRateObservations(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"XXX\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"1\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_InvalidTargetCurrency(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"EUR\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"null\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"0\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"0\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNorgesBankDataSource_InvalidUnit(t *testing.T) {
|
||||
dataSource := &NorgesBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"null\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>"+
|
||||
"<message:StructureSpecificData xmlns:ss=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific\" xmlns:footer=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message/footer\" xmlns:ns1=\"urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD\" xmlns:message=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message\" xmlns:common=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xml=\"http://www.w3.org/XML/1998/namespace\" xsi:schemaLocation=\"http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message https://registry.sdmx.org/schemas/v2_1/SDMXMessage.xsd urn:sdmx:org.sdmx.infomodel.datastructure.Dataflow=NB:EXR(1.0):ObsLevelDim:TIME_PERIOD https://data.norges-bank.no/api/schema/dataflow/NB/EXR/1.0?format=sdmx-2.1\">"+
|
||||
" <message:DataSet>\n"+
|
||||
" <Series BASE_CUR=\"USD\" QUOTE_CUR=\"NOK\" UNIT_MULT=\"-1\">\n"+
|
||||
" <Obs TIME_PERIOD=\"2024-11-15\" OBS_VALUE=\"11.0545\" />\n"+
|
||||
" </Series>\n"+
|
||||
" </message:DataSet>\n"+
|
||||
"</message:StructureSpecificData>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
@@ -122,15 +126,24 @@ func (e *ReserveBankOfAustraliaExchangeRate) ToLatestExchangeRate() *models.Late
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequestUrls returns the the reserve bank of Australia data source urls
|
||||
func (e *ReserveBankOfAustraliaDataSource) GetRequestUrls() []string {
|
||||
return []string{reserveBankOfAustraliaExchangeRateUrl}
|
||||
// BuildRequests returns the reserve bank of Australia exchange rates http requests
|
||||
func (e *ReserveBankOfAustraliaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", reserveBankOfAustraliaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the the reserve bank of Australia data source raw response
|
||||
func (e *ReserveBankOfAustraliaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
reserveBankOfAustraliaData := &ReserveBankOfAustraliaData{}
|
||||
err := xml.Unmarshal(content, reserveBankOfAustraliaData)
|
||||
err := xmlDecoder.Decode(reserveBankOfAustraliaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
|
||||
@@ -49,6 +49,15 @@ func TestReserveBankOfAustraliaDataSource_StandardDataExtractBaseCurrency(t *tes
|
||||
assert.Equal(t, "AUD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617255900), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const swissNationalBankExchangeRateUrl = "https://www.snb.ch/public/en/rss/exchangeRates"
|
||||
const swissNationalBankExchangeRateReferenceUrl = "https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates"
|
||||
const swissNationalBankDataSource = "Schweizerische Nationalbank"
|
||||
const swissNationalBankBaseCurrency = "CHF"
|
||||
|
||||
const swissNationalBankDataUpdateDateFormat = "Mon, 02 Jan 2006 15:04:05 MST"
|
||||
const swissNationalBankExchangeRatePeriodDateFormat = "2006-01-02"
|
||||
|
||||
// SwissNationalBankDataSource defines the structure of exchange rates data source of the reserve Swiss National Bank
|
||||
type SwissNationalBankDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// SwissNationalBankData represents the whole data from the reserve Swiss National Bank
|
||||
type SwissNationalBankData struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Channel *SwissNationalBankRssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
// SwissNationalBankRssChannel represents the rss channel from the reserve Swiss National Bank
|
||||
type SwissNationalBankRssChannel struct {
|
||||
PublishDate string `xml:"pubDate"`
|
||||
Items []*SwissNationalBankChannelItem `xml:"item"`
|
||||
}
|
||||
|
||||
// SwissNationalBankChannelItem represents the channel item from the reserve Swiss National Bank
|
||||
type SwissNationalBankChannelItem struct {
|
||||
Statistics *SwissNationalBankItemStatistics `xml:"statistics"`
|
||||
}
|
||||
|
||||
// SwissNationalBankItemStatistics represents the item statistics from the reserve Swiss National Bank
|
||||
type SwissNationalBankItemStatistics struct {
|
||||
ExchangeRate *SwissNationalBankExchangeRate `xml:"exchangeRate"`
|
||||
}
|
||||
|
||||
// SwissNationalBankExchangeRate represents the exchange rate from the reserve Swiss National Bank
|
||||
type SwissNationalBankExchangeRate struct {
|
||||
BaseCurrency string `xml:"baseCurrency"`
|
||||
TargetCurrency string `xml:"targetCurrency"`
|
||||
Observation *SwissNationalBankExchangeRateObservation `xml:"observation"`
|
||||
ObservationPeriod *SwissNationalBankExchangeRateObservationPeriod `xml:"observationPeriod"`
|
||||
}
|
||||
|
||||
// SwissNationalBankExchangeRateObservation represents the exchange rate data from the reserve Swiss National Bank
|
||||
type SwissNationalBankExchangeRateObservation struct {
|
||||
Value string `xml:"value"`
|
||||
Unit string `xml:"unit"`
|
||||
UnitExponent string `xml:"unit_mult"`
|
||||
}
|
||||
|
||||
// SwissNationalBankExchangeRateObservationPeriod represents the exchange rate period data from the reserve Swiss National Bank
|
||||
type SwissNationalBankExchangeRateObservationPeriod struct {
|
||||
Period string `xml:"period"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the reserve Swiss National Bank
|
||||
func (e *SwissNationalBankData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e.Channel == nil {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] rss channel does not exist")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(e.Channel.Items) < 1 {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] channel items is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestCurrencyExchangeRateDate := make(map[string]int64)
|
||||
latestExchangeRates := make(map[string]*models.LatestExchangeRate)
|
||||
|
||||
for i := 0; i < len(e.Channel.Items); i++ {
|
||||
item := e.Channel.Items[i]
|
||||
|
||||
if item.Statistics == nil || item.Statistics.ExchangeRate == nil || item.Statistics.ExchangeRate.Observation == nil || item.Statistics.ExchangeRate.ObservationPeriod == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.Statistics.ExchangeRate.BaseCurrency != swissNationalBankBaseCurrency || item.Statistics.ExchangeRate.Observation.Unit != swissNationalBankBaseCurrency {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[item.Statistics.ExchangeRate.TargetCurrency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
date, err := time.Parse(swissNationalBankExchangeRatePeriodDateFormat, item.Statistics.ExchangeRate.ObservationPeriod.Period)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] failed to parse exchange rate period date, period is %s", item.Statistics.ExchangeRate.ObservationPeriod.Period)
|
||||
continue
|
||||
}
|
||||
|
||||
currency := item.Statistics.ExchangeRate.TargetCurrency
|
||||
latestDate, exists := latestCurrencyExchangeRateDate[currency]
|
||||
|
||||
if !exists || date.Unix() > latestDate {
|
||||
finalExchangeRate := item.Statistics.ExchangeRate.ToLatestExchangeRate(c)
|
||||
|
||||
if finalExchangeRate != nil {
|
||||
latestCurrencyExchangeRateDate[currency] = date.Unix()
|
||||
latestExchangeRates[currency] = finalExchangeRate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Channel.Items))
|
||||
|
||||
for _, exchangeRate := range latestExchangeRates {
|
||||
exchangeRates = append(exchangeRates, exchangeRate)
|
||||
}
|
||||
|
||||
updateDateTime := e.Channel.PublishDate
|
||||
updateTime, err := time.Parse(swissNationalBankDataUpdateDateFormat, updateDateTime)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: swissNationalBankDataSource,
|
||||
ReferenceUrl: swissNationalBankExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: swissNationalBankBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from the reserve Swiss National Bank
|
||||
func (e *SwissNationalBankExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Observation.Value)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.TargetCurrency, e.Observation.Value)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.TargetCurrency, e.Observation.Value)
|
||||
return nil
|
||||
}
|
||||
|
||||
unitExponent, err := utils.StringToInt(e.Observation.UnitExponent)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit exponent is %s", e.TargetCurrency, e.Observation.UnitExponent)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := 1 / rate
|
||||
|
||||
if unitExponent > 1 {
|
||||
finalRate = finalRate / math.Pow10(unitExponent-1)
|
||||
} else if unitExponent < 0 {
|
||||
finalRate = finalRate * math.Pow10(-unitExponent)
|
||||
} else if unitExponent == 0 {
|
||||
log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] unit exponent is zero, currency is %s", e.TargetCurrency)
|
||||
return nil
|
||||
}
|
||||
|
||||
if math.IsInf(finalRate, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.TargetCurrency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the Swiss National Bank exchange rates http requests
|
||||
func (e *SwissNationalBankDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", swissNationalBankExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the the reserve Swiss National Bank data source raw response
|
||||
func (e *SwissNationalBankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
swissNationalBankData := &SwissNationalBankData{}
|
||||
err := xmlDecoder.Decode(swissNationalBankData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := swissNationalBankData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[swiss_national_bank_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const SwissNationalBankMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n" +
|
||||
" <item>\n" +
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:value>0.9378</cb:value>\n" +
|
||||
" <cb:unit>CHF</cb:unit>\n" +
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n" +
|
||||
" </cb:observation>\n" +
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n" +
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n" +
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:period>2024-11-12</cb:period>\n" +
|
||||
" </cb:observationPeriod>\n" +
|
||||
" </cb:exchangeRate>\n" +
|
||||
" </cb:statistics>\n" +
|
||||
" </item>\n" +
|
||||
" <item>\n" +
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:value>0.5727</cb:value>\n" +
|
||||
" <cb:unit>CHF</cb:unit>\n" +
|
||||
" <cb:unit_mult>-2</cb:unit_mult>\n" +
|
||||
" </cb:observation>\n" +
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n" +
|
||||
" <cb:targetCurrency>JPY</cb:targetCurrency>\n" +
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:period>2024-11-12</cb:period>\n" +
|
||||
" </cb:observationPeriod>\n" +
|
||||
" </cb:exchangeRate>\n" +
|
||||
" </cb:statistics>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
func TestSwissNationalBankDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "CHF", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1731409250), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "EUR",
|
||||
Rate: "1.0663254425250588",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "JPY",
|
||||
Rate: "174.6114894360049",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_MultipleDateExchanges(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9381</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-11</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "EUR",
|
||||
Rate: "1.0663254425250588",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_EmptyRDFContent(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
"</rss>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_EmptyChannelContent(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_NoItem(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_BaseCurrencyNotEqualPreset(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>EUR</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>CHF</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_UnitCurrencyNotEqualPreset(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>EUR</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>XXX</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value></cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>null</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>1</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestSwissNationalBankDataSource_InvalidUnit(t *testing.T) {
|
||||
dataSource := &SwissNationalBankDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>null</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
|
||||
actualLatestExchangeRateResponse, err = dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" version=\"2.0\">\n"+
|
||||
" <channel>\n"+
|
||||
" <pubDate>Tue, 12 Nov 2024 11:00:50 GMT</pubDate>\n"+
|
||||
" <item>\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.9378</cb:value>\n"+
|
||||
" <cb:unit>CHF</cb:unit>\n"+
|
||||
" <cb:unit_mult>0</cb:unit_mult>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>CHF</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>EUR</cb:targetCurrency>\n"+
|
||||
" <cb:observationPeriod rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:period>2024-11-12</cb:period>\n"+
|
||||
" </cb:observationPeriod>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
" </channel>\n"+
|
||||
"</rss>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user