Compare commits

..

1041 Commits

Author SHA1 Message Date
MaysWind 210d978279 support filtering transactions in import transaction dialog 2024-11-03 20:59:00 +08:00
MaysWind a35771acc4 fix cannot batch replace tag in import transaction dialog sometimes 2024-11-03 20:50:28 +08:00
MaysWind 637faef690 upgrade golang to 1.22.8, node.js to 20.18.0, alpine base image to 3.20.3 2024-11-03 00:42:06 +08:00
MaysWind c800eb5d4d change transaction type of credit card payment to transfer transaction 2024-11-03 00:05:12 +08:00
MaysWind 0e062ed065 code refactor 2024-11-02 22:47:33 +08:00
MaysWind f2e89da724 support ofx 1.x 2024-11-02 02:05:55 +08:00
MaysWind ac29f0bf98 read and check ofx 2.x file header 2024-11-01 00:11:26 +08:00
MaysWind d174e99c80 code refactor 2024-10-31 00:57:34 +08:00
MaysWind 5006a96181 parse xml using the encoding declared in xml header 2024-10-30 23:51:07 +08:00
MaysWind ce8c020477 support transfer in transaction 2024-10-30 23:42:17 +08:00
MaysWind 98c96b8217 support qpf file extension 2024-10-30 22:51:41 +08:00
MaysWind 43404adf49 code refactor 2024-10-30 22:45:44 +08:00
MaysWind 90ea462206 code refactor 2024-10-30 01:05:04 +08:00
MaysWind 92a78f6f12 add unit tests 2024-10-30 00:55:52 +08:00
MaysWind be7fbd405e add comment 2024-10-30 00:45:01 +08:00
MaysWind 98e3c6ebfd add unit test 2024-10-30 00:40:58 +08:00
MaysWind fbca205cca add unit test and improve robustness 2024-10-30 00:30:33 +08:00
MaysWind d3c25a1aff code refactor 2024-10-29 23:39:58 +08:00
MaysWind 84f2778bc0 add comment 2024-10-29 23:39:01 +08:00
MaysWind 688185c367 add comment 2024-10-29 23:33:39 +08:00
MaysWind bf48bfdd7c add unit tests 2024-10-29 23:15:02 +08:00
MaysWind bde0b01d06 add unit tests 2024-10-29 00:56:46 +08:00
MaysWind a1b7c8ad1d check whether amount is less than 0 for transfer transaction 2024-10-29 00:50:37 +08:00
MaysWind 37ff0d1fab check whether split quantity node exists 2024-10-29 00:38:24 +08:00
MaysWind 0c218df3ad use extrame/xls library to replace xlsReader 2024-10-29 00:35:10 +08:00
MaysWind 259f27bf1b support parsing account to 2024-10-28 23:55:34 +08:00
MaysWind f2bc8e44fc add unit test and improve robustness 2024-10-28 23:46:45 +08:00
MaysWind c44bf73b42 add unit test 2024-10-28 22:59:46 +08:00
MaysWind fbd19f9da4 support comma as decimal point 2024-10-28 22:44:03 +08:00
MaysWind 50fc0783d4 change type name 2024-10-28 22:43:17 +08:00
MaysWind c372272394 import transactions from ofx 2.x file 2024-10-28 01:39:03 +08:00
MaysWind 22d653cc76 add unit test 2024-10-27 23:52:06 +08:00
MaysWind 46dbfcbe77 use name column as description 2024-10-27 23:50:52 +08:00
MaysWind 91d51e660b support parsing amount with thousandss eparator 2024-10-27 17:21:49 +08:00
MaysWind 76f5f12563 support year/month/day format date 2024-10-27 17:15:59 +08:00
MaysWind 08bc0eff8c not required transaction type column 2024-10-27 17:09:23 +08:00
MaysWind be1d219fea update Chinese transaction 2024-10-27 16:02:01 +08:00
MaysWind 52034ef55c code refactor 2024-10-27 11:21:23 +08:00
MaysWind 47ab41088e change csv and tsv to subtypes when importing ezbookkeeping exported files in import transaction dialog 2024-10-27 01:54:26 +08:00
MaysWind fb5484f44d import transactions from iif file 2024-10-27 01:22:07 +08:00
MaysWind cfbab0432c fix npe 2024-10-26 22:44:42 +08:00
MaysWind 34bf74da84 fix typo 2024-10-26 19:48:24 +08:00
MaysWind 889f90015a remove redundant code 2024-10-26 19:16:37 +08:00
MaysWind 1d0817b1b3 fix parsing amount for expense transaction 2024-10-22 00:22:57 +08:00
MaysWind 54150a9157 modify anchor name 2024-10-21 22:24:54 +08:00
MaysWind a8a89ca089 modify anchor name 2024-10-21 01:08:53 +08:00
MaysWind bb4eca1b0c import transaction from GnuCash database 2024-10-21 01:02:37 +08:00
MaysWind 6ce6fd3aa8 modify parameter name 2024-10-20 23:25:57 +08:00
MaysWind 35ec18cfac code refactor 2024-10-20 21:38:51 +08:00
MaysWind 3795e788bb change display order 2024-10-20 19:21:49 +08:00
MaysWind 4b239030c5 improve the compatibility of qif files 2024-10-20 15:56:19 +08:00
MaysWind 7162ce4a77 fix the wrong amount and account name of transfer in transaction 2024-10-20 15:47:30 +08:00
MaysWind 03f0e4a477 modify error message 2024-10-20 10:12:00 +08:00
MaysWind a23a194660 add subtypes to imported file types 2024-10-20 10:05:44 +08:00
MaysWind 45faa269a4 add comment 2024-10-20 01:54:03 +08:00
MaysWind 981a1aac4f import transaction from qif file 2024-10-20 01:47:54 +08:00
MaysWind 70ccf7b691 code refactor 2024-10-19 19:18:03 +08:00
MaysWind 8bc763be9b code refactor 2024-10-18 00:53:10 +08:00
MaysWind 815bb08fa9 fix wrong log 2024-10-18 00:20:17 +08:00
MaysWind 34773537c2 code refactor 2024-10-17 01:13:35 +08:00
MaysWind 6c285a0856 modify function name 2024-10-16 23:18:12 +08:00
MaysWind a062592043 rename files 2024-10-16 00:46:30 +08:00
MaysWind 8978e340c7 add unit test 2024-10-15 00:33:59 +08:00
MaysWind 07c1bba829 code refactor 2024-10-15 00:33:45 +08:00
MaysWind 4f836f5e3a get function of writable transaction data table supports data row parser, column count and get data function of writable transaction data row supports filtering defined columns add related unit tests 2024-10-14 23:57:26 +08:00
MaysWind 2cfc24a808 update log content 2024-10-14 23:55:41 +08:00
MaysWind 07743368f4 code refactor 2024-10-14 23:13:50 +08:00
MaysWind 592c04c5ab code refactor 2024-10-14 23:11:54 +08:00
MaysWind bb8a72876b code refactor 2024-10-14 23:09:17 +08:00
MaysWind b9b501edfa import transaction from wechat pay billing file 2024-10-14 01:21:41 +08:00
MaysWind 44fe7778b6 code refactor 2024-10-14 00:31:09 +08:00
MaysWind 6ea5ad1619 change file type name 2024-10-13 23:49:38 +08:00
MaysWind d9b819d1a1 code refactor 2024-10-13 19:44:29 +08:00
MaysWind 5ac9eb5d5c fix cannot import transaction from firefly iii when only have minimal required columns, add unit tests 2024-10-12 23:52:42 +08:00
MaysWind 1345603e09 update documents anchor 2024-10-12 22:19:53 +08:00
MaysWind bd66408c3d import transaction from firefly iii 2024-10-12 01:17:56 +08:00
MaysWind f75e078fed code refactor 2024-10-11 00:40:39 +08:00
MaysWind 09fc82f7b7 code refactor 2024-10-11 00:03:21 +08:00
MaysWind 7bc9a0357e code refactor 2024-10-10 23:53:11 +08:00
MaysWind dc6420ccb0 add unit test 2024-10-10 01:03:10 +08:00
MaysWind fadf72c245 modify file name 2024-10-09 01:36:44 +08:00
MaysWind c8ff60d986 fix account balance calculation error after modify the amount of balance modification transaction 2024-10-09 01:36:36 +08:00
MaysWind e5cd8ffa61 not allow to add transaction before balance modification transaction and not allow to modify transaction time for balance modification transaction 2024-10-09 01:35:48 +08:00
MaysWind c36f58e491 add documents link 2024-10-09 00:25:00 +08:00
MaysWind 45d348c0ef support importing transaction data from alipay app 2024-10-08 00:23:22 +08:00
MaysWind ae26f00a36 code refactor 2024-10-07 23:54:50 +08:00
MaysWind a6e765f51c show export data guide in import transaction dialog 2024-10-07 20:54:23 +08:00
MaysWind 3c428ade52 modify text 2024-10-07 20:02:31 +08:00
MaysWind 011020a945 show language name in current language 2024-10-07 19:58:58 +08:00
MaysWind 368322f906 initial display mode of the date time sheet in mobile version depends on the click content (#28) 2024-09-26 00:46:42 +08:00
MaysWind a3ff181b6e not allow to input non number to the amount input box in desktop version via mobile device 2024-09-25 23:17:55 +08:00
MaysWind 720a5f8897 modify style 2024-09-25 22:40:50 +08:00
MaysWind 633cb44db6 code refactor 2024-09-25 22:38:38 +08:00
MaysWind 73f234d8f5 not allow to click continue button or batch replace menu when there is an editing transaction, only update the display transaction content after clicking the confirm button 2024-09-24 01:29:46 +08:00
MaysWind a49490baa7 support categories with the same name but different types when import transaction 2024-09-24 01:02:05 +08:00
MaysWind a90f08a85f support multi sort in import dialog 2024-09-24 00:22:35 +08:00
MaysWind 17ee037525 modify style 2024-09-24 00:13:07 +08:00
MaysWind 75aa55d340 fix the sorting order does not change after modifying category or account 2024-09-23 23:57:30 +08:00
MaysWind d32cd793d0 support batch replace category / account / tag in import transaction dialog 2024-09-23 23:47:02 +08:00
MaysWind 29781bbac4 support batch replace category / account / tag in import transaction dialog 2024-09-23 00:19:03 +08:00
MaysWind 21ea36a4f7 code refactor 2024-09-22 23:39:01 +08:00
MaysWind 5e99b9d555 remove unused code 2024-09-22 21:59:14 +08:00
MaysWind 3190608d36 support choosing invalid items in import transaction dialog 2024-09-22 21:50:41 +08:00
MaysWind 4d0aecb8c2 modify style 2024-09-22 21:35:01 +08:00
MaysWind e1f420c3ae add dropdown page select box in import transaction dialog 2024-09-22 21:31:48 +08:00
MaysWind cbf3dd9776 modify style 2024-09-22 19:48:47 +08:00
MaysWind 0ff97ac4e0 check error message 2024-09-22 19:34:22 +08:00
MaysWind 52b37c2a13 support importing transaction data from alipay export file 2024-09-22 19:27:21 +08:00
MaysWind 732fa3b9de code refactor 2024-09-22 16:46:35 +08:00
MaysWind 4047aaf48a add comment 2024-09-22 16:46:27 +08:00
MaysWind bc3e7ae29b show file extensions in import dialog 2024-09-22 16:46:19 +08:00
MaysWind ed87e56a33 code refactor 2024-09-22 16:46:01 +08:00
MaysWind 28ce1e856c modify style 2024-09-22 15:01:29 +08:00
MaysWind 4c13b7ad02 automatically save transaction draft 2024-09-22 14:41:35 +08:00
MaysWind 49df497f35 code refactor 2024-09-20 23:38:43 +08:00
MaysWind 5221ab481e show a confirmation dialog allowing the user to change the editable transaction range to "All" when unable to add or edit a transaction due to the limitation on the editable transaction range 2024-09-20 00:47:53 +08:00
MaysWind 6655d725ae code refactor 2024-09-20 00:10:40 +08:00
MaysWind 220f9f15e5 modify the name of debit card to checking account, add savings account and certificate of deposit 2024-09-20 00:00:19 +08:00
MaysWind 1e8a27612f code refactor 2024-09-19 23:07:12 +08:00
MaysWind 7ecec2bb64 code refactor 2024-09-19 00:03:07 +08:00
MaysWind fceb92eb6f change the type of balance modification transaction to income or expense for imported feidee mymoney transaction data 2024-09-18 23:04:00 +08:00
MaysWind 8b92051900 update splash screen images for ios 2024-09-17 20:49:02 +08:00
MaysWind 03f3e039e0 add more conditions for sorting of ImportTransaction 2024-09-17 19:40:08 +08:00
MaysWind 18ebf7baaf code refactor 2024-09-17 19:40:02 +08:00
MaysWind 20b28f2a68 support importing transaction data from feidee mymoney app export data 2024-09-17 19:39:50 +08:00
MaysWind 6d0fdc6860 code refactor 2024-09-17 17:06:13 +08:00
MaysWind 9f0e82446e update comments 2024-09-17 09:48:01 +08:00
MaysWind cb69991f7f update documents 2024-09-13 20:46:24 +08:00
MaysWind 327fdd66e4 update unit test 2024-09-12 00:10:05 +08:00
MaysWind 7d01b4bd5a modify style 2024-09-12 00:09:54 +08:00
MaysWind d15a862e5b support importing feidee mymoney web export data 2024-09-12 00:08:28 +08:00
MaysWind 5a31118c96 code refactor 2024-09-11 23:51:47 +08:00
MaysWind 8eaeb1953b modify style 2024-09-11 23:04:18 +08:00
MaysWind 25674c04c8 allow import file without sub category name 2024-09-11 02:09:12 +08:00
MaysWind cd6e7c81e5 code refactor 2024-09-11 01:40:42 +08:00
MaysWind d915de8ff9 allow import file does not include transfer in amounts 2024-09-11 01:38:57 +08:00
MaysWind 1307d49762 code refactor 2024-09-11 01:08:43 +08:00
MaysWind 2cffd4fbbb code refactor 2024-09-11 00:57:16 +08:00
MaysWind 031209490f add more error hints 2024-09-11 00:48:18 +08:00
MaysWind 5d75629a73 code refactor 2024-09-11 00:25:11 +08:00
MaysWind 27c4afd41b code refactor 2024-09-10 23:31:57 +08:00
MaysWind 9db4a2430a modify style 2024-09-10 01:04:17 +08:00
MaysWind e1ac3732bd improve performance 2024-09-10 00:37:22 +08:00
MaysWind 56ad572387 code refactor 2024-09-10 00:32:36 +08:00
MaysWind 70beb45c4e add logs 2024-09-10 00:23:31 +08:00
MaysWind 698c0a62a2 support import transaction tags 2024-09-10 00:16:06 +08:00
MaysWind 8421649bcc modify style 2024-09-10 00:02:16 +08:00
MaysWind e8883781e5 update timeout 2024-09-09 23:32:42 +08:00
MaysWind 77e9ae94cf code refactor 2024-09-09 23:26:08 +08:00
MaysWind 30344ef5cb disable touch for v-window 2024-09-09 22:29:59 +08:00
MaysWind fa0460abd0 update eslint config 2024-09-09 01:39:41 +08:00
MaysWind ee52db3f7c make tags of exported transaction data in the same order as they displayed in transaction 2024-09-09 01:34:38 +08:00
MaysWind 00c8259bd0 update timeout 2024-09-09 01:34:27 +08:00
MaysWind 470a74f420 support importing transaction in frontend 2024-09-09 01:34:14 +08:00
MaysWind 3d5a03a629 code refactor 2024-09-08 23:56:21 +08:00
MaysWind cc8646cf1b add log 2024-09-07 22:31:30 +08:00
MaysWind 308c89aa0b skip empty line 2024-09-07 21:47:32 +08:00
MaysWind de37c3da5a code refactor 2024-09-07 21:06:01 +08:00
MaysWind 593b924f32 add comment 2024-09-07 14:29:02 +08:00
MaysWind bc3cb79f91 code refactor 2024-09-07 03:01:57 +08:00
MaysWind 9622d5de06 limit the maximum size of upload pictures 2024-09-06 23:34:35 +08:00
MaysWind 2dddb77ca4 fix there are unnecessary separators in exported file when the tag in transaction does not exist, fix the incorrect exported data when the content contains CR("\r") 2024-09-03 00:47:47 +08:00
MaysWind 1d43eda9b7 add unit test 2024-09-03 00:16:41 +08:00
MaysWind dbb1843285 code refactor 2024-09-02 23:37:33 +08:00
MaysWind dfe1b853d1 code refactor 2024-09-02 23:36:54 +08:00
MaysWind c7e4d4eaae add log 2024-09-02 23:31:43 +08:00
MaysWind 7c59e8386e support importing transaction by csv/tsv file via command line 2024-09-02 01:40:04 +08:00
MaysWind 366311edbb fix incorrect transaction amount in exported data 2024-09-01 22:15:56 +08:00
MaysWind 2fc6a6ca77 modify style 2024-09-01 17:52:18 +08:00
MaysWind 9945cb7a94 modify style 2024-09-01 12:08:26 +08:00
MaysWind 50918756d7 modify style 2024-09-01 12:06:43 +08:00
MaysWind 43c37763d8 code refactor 2024-09-01 00:49:06 +08:00
MaysWind 4e365f54af code refactor 2024-09-01 00:43:40 +08:00
MaysWind 09ddf53b01 limit maximum count of tags in a transaction 2024-09-01 00:33:11 +08:00
MaysWind 7fbfa71434 support transaction pictures 2024-09-01 00:30:56 +08:00
MaysWind ae46cd2332 fix other transactions could not be created with the same transaction time of the deleted transaction 2024-08-31 15:40:22 +08:00
MaysWind 772a22a182 add transaction pictures api 2024-08-31 15:34:24 +08:00
MaysWind 636ac974b8 show transaction pictures in data management page 2024-08-30 21:58:25 +08:00
MaysWind 216c8211ac code refactor 2024-08-30 21:57:06 +08:00
MaysWind 805d3e65e3 update comment 2024-08-30 00:40:59 +08:00
MaysWind 73c69c3761 add transaction picture upload api 2024-08-30 00:33:48 +08:00
MaysWind fe442f27f2 update comment 2024-08-29 22:44:05 +08:00
MaysWind 8b51f6ebaa add unit tests 2024-08-28 23:42:13 +08:00
MaysWind ab745ad56b add International Monetary Fund exchange rates data source 2024-08-28 01:12:16 +08:00
MaysWind 62d3dc63d1 update comments 2024-08-28 00:08:23 +08:00
MaysWind fcfd9894a3 fix wrong timezone in template edit page / dialog 2024-08-27 23:34:40 +08:00
MaysWind df076b563a add tooltip to notification 2024-08-27 23:13:27 +08:00
MaysWind 366fbff012 improve auto scroll for multi-selected 2024-08-27 23:02:42 +08:00
MaysWind d8f7175da9 code refactor 2024-08-27 22:53:36 +08:00
MaysWind 2bd3845d22 update comments 2024-08-27 22:41:38 +08:00
MaysWind 720f83bd0b show multiple selected week days in the order of the week day list 2024-08-26 23:32:12 +08:00
MaysWind c2fbd918dd limit the count of tags in transaction template 2024-08-26 23:21:06 +08:00
MaysWind 902361e5d6 fix wrong time zone minutes offset for Marquesas Islands and Newfoundland 2024-08-26 23:08:07 +08:00
MaysWind 5a2576b368 add unit tests 2024-08-26 22:56:32 +08:00
MaysWind d71014a797 hide scheduled transaction in settings page when scheduled transaction is not enabled 2024-08-26 22:41:57 +08:00
MaysWind d2eaf5c6da support scheduled transaction (#2) 2024-08-26 02:30:16 +08:00
MaysWind 17d4fec256 modify variable name 2024-08-20 00:37:03 +08:00
MaysWind 4a96bac457 don't show the editable icon when user avatar cannot be modified 2024-08-19 23:56:03 +08:00
MaysWind 4977979b08 code refactor 2024-08-19 23:47:55 +08:00
MaysWind 8fa19df113 add log 2024-08-19 22:57:08 +08:00
MaysWind 217d37e3d3 fix gocron log format 2024-08-19 01:10:28 +08:00
MaysWind e86d4e05ce code refactor 2024-08-19 00:35:45 +08:00
MaysWind 6fcb0a2b3c remove deprecated Monetary Authority of Singapore exchange rates api 2024-08-18 00:43:02 +08:00
MaysWind 560edf9fbf code refactor 2024-08-17 00:38:59 +08:00
MaysWind e532f372b5 code refactor 2024-08-16 21:01:35 +08:00
MaysWind 1101796641 support number pad keys in pin code input and amount input 2024-08-13 02:08:42 +08:00
MaysWind c2757f68a6 code refactor and add unit tests 2024-08-13 01:29:51 +08:00
MaysWind d648226d13 add unit tests 2024-08-12 23:52:05 +08:00
MaysWind 4987819227 remove unused code 2024-08-12 23:21:07 +08:00
MaysWind 8d27997e2e fix typo 2024-08-12 22:49:07 +08:00
MaysWind b9ee94a47d remove unused code 2024-08-12 22:47:30 +08:00
MaysWind 8b531cc726 fix the invalid month in the mobile version transaction list page 2024-08-12 22:44:48 +08:00
MaysWind 753fc762a0 remove unused code 2024-08-12 01:14:52 +08:00
MaysWind 80396a444e fix the page could not load properly when selecting the same date in trend analysis 2024-08-12 01:06:03 +08:00
MaysWind 52dfee9ca6 support periodically cleaning up expired tokens 2024-08-12 00:49:07 +08:00
MaysWind 80b8b9afdd change parameter name 2024-08-11 21:11:14 +08:00
MaysWind 20dc72022d code refactor 2024-08-11 20:36:32 +08:00
MaysWind 9116f404db upgrade third party dependencies 2024-08-11 17:13:37 +08:00
MaysWind 029e5f6d02 upgrade golang to 1.22, node.js to v20, alpine base image to 3.20.2 2024-08-11 12:48:43 +08:00
MaysWind 6ccaf89d86 fix the user avatar in the top right corner does not update in the desktop version 2024-08-11 12:48:43 +08:00
MaysWind 0d706abbd3 don't remove old user custom avatar when old custom avatar type is empty 2024-08-11 12:48:43 +08:00
MaysWind f4a27e59a3 check whether query is valid before query user from database 2024-08-11 12:48:43 +08:00
MaysWind 5ab7d0e9b3 bump version to 0.6.0 2024-08-11 12:48:11 +08:00
MaysWind d198634326 bump version to 0.6.0 2024-08-11 00:22:48 +08:00
MaysWind d3762e6c46 add storage directory in built package 2024-08-10 19:08:56 +08:00
MaysWind 157eb140eb hide multiple accounts / categories item when there are no accounts / categories 2024-08-10 00:40:03 +08:00
MaysWind 83a0c27259 hide secret in boot log 2024-08-10 00:10:15 +08:00
MaysWind 5c4a8e37c4 verify whether required items is valid before submitting new transaction 2024-08-09 00:18:52 +08:00
MaysWind e4faf64ea3 redesign the default request id generator, replace random number to client port 2024-08-09 00:07:32 +08:00
MaysWind a4849fa4f0 add notification when user registers 2024-08-08 22:29:01 +08:00
MaysWind 32155ca63d code refactor 2024-08-08 22:26:57 +08:00
MaysWind 9ea3327517 don't create log file if request log or query log are not enabled 2024-08-08 22:18:56 +08:00
MaysWind 946a7810a7 support logging request logs and database query logs to separate files, and support rotating log files 2024-08-08 02:03:57 +08:00
MaysWind ea8021b359 modify log format 2024-08-07 22:47:56 +08:00
MaysWind caa27841ef fix the incorrect calculation of monthly income and expense amount when filtering multiple accounts 2024-08-07 00:54:57 +08:00
MaysWind d1cd13723a add online demo url 2024-08-06 01:11:57 +08:00
MaysWind 0e946a4b3b show notification in frontend 2024-08-06 00:40:27 +08:00
MaysWind 051c319890 use client language if user language is set to system default 2024-08-05 23:58:52 +08:00
MaysWind f2baa4ae65 code refactor 2024-08-05 01:29:53 +08:00
MaysWind 05a93667eb display notification every time users open the app or login 2024-08-05 01:25:26 +08:00
MaysWind c137156c97 code refactor 2024-08-05 01:15:33 +08:00
MaysWind 8f8a94cd66 add comment 2024-08-05 00:41:46 +08:00
MaysWind 4cdb599bf3 add tips 2024-08-05 00:22:58 +08:00
MaysWind 77a5ccd796 support editing templates for mobile version 2024-08-04 23:28:15 +08:00
MaysWind 99ae18d06d code refactor 2024-08-04 19:51:21 +08:00
MaysWind 90318d5690 fix adding transaction bug on desktop version 2024-08-04 19:50:33 +08:00
MaysWind f133692002 check whether input is valid before submitting 2024-08-04 19:50:26 +08:00
MaysWind 0b17251f94 modify style 2024-08-04 19:50:17 +08:00
MaysWind 24724bb19f show transaction template count in data management page 2024-08-04 19:50:04 +08:00
MaysWind b23d630daa code refactor 2024-08-04 16:21:13 +08:00
MaysWind fcb954ff40 support creating transaction from template 2024-08-04 01:07:50 +08:00
MaysWind 889225301c add translating guide link 2024-08-03 21:15:33 +08:00
MaysWind 816d0e7ceb code refactor 2024-08-03 19:24:59 +08:00
MaysWind f2c0ffab99 code refactor 2024-08-03 19:11:30 +08:00
MaysWind f1f61a9038 code refactor 2024-08-03 18:21:04 +08:00
MaysWind 77439f675b code refactor 2024-08-03 17:04:29 +08:00
MaysWind c57f17233a support currency unit name 2024-08-03 16:54:54 +08:00
MaysWind c91a56547f update currency symbols and currency symbol supports plural symbol 2024-08-03 16:06:59 +08:00
MaysWind 10dc2d1713 update currency names 2024-08-03 15:38:06 +08:00
MaysWind d3c8a520ca update latest currencies 2024-08-02 00:57:23 +08:00
MaysWind 681f888529 fix the amount in transaction edit dialog may not display after unhiding the amount 2024-08-01 00:54:55 +08:00
MaysWind bce8c23b05 fix the incorrect range of editable transaction range 2024-08-01 00:45:57 +08:00
MaysWind 788bfa7d4b fix the default user avatar not display when user cannot load user avatar url 2024-08-01 00:39:00 +08:00
MaysWind 6d331c873b check whether account category is valid when creating account 2024-08-01 00:17:17 +08:00
MaysWind a03df7ed36 create transaction template and modify template name in edit dialog 2024-08-01 00:04:07 +08:00
MaysWind b0b330903c transaction template supports setting whether hide amount 2024-07-30 00:08:48 +08:00
MaysWind 4e16f963a8 modify style 2024-07-29 23:56:33 +08:00
MaysWind 8ef4b5537c display a tooltip icon when hover the avatar 2024-07-29 23:54:52 +08:00
MaysWind 8273e06e43 code refactor 2024-07-29 23:08:04 +08:00
MaysWind b91305c490 modify setting key 2024-07-29 22:37:59 +08:00
MaysWind de086aa29e add transaction template 2024-07-29 01:27:11 +08:00
MaysWind 4c69243bef modify log 2024-07-28 20:16:02 +08:00
MaysWind 57d8edea0a modify index 2024-07-28 18:32:59 +08:00
MaysWind a098c100d7 code refactor 2024-07-28 18:01:27 +08:00
MaysWind 8fb209440d update comment 2024-07-28 17:36:51 +08:00
MaysWind d05736d0eb support minio as object storage 2024-07-28 17:36:42 +08:00
MaysWind c92a9e61b0 code refactor 2024-07-28 16:04:34 +08:00
MaysWind 2e04affb00 supports local file system object storage and use it as the default avatar provider 2024-07-28 16:03:20 +08:00
MaysWind 731b6e8bad update comment 2024-07-27 22:08:08 +08:00
MaysWind 2b63e50837 fix category name not show in transaction edit dialog when category is hidden 2024-07-26 00:50:58 +08:00
MaysWind e22f512f22 show "without tags" in table header 2024-07-26 00:29:42 +08:00
MaysWind 9a83393290 modify style 2024-07-26 00:28:08 +08:00
MaysWind 30ebe49875 allow to filter without tags 2024-07-26 00:22:11 +08:00
MaysWind e15a850cfe allow to filter with all tags 2024-07-26 00:07:59 +08:00
MaysWind 34ebc06a8d aadd transaction tag filter to backend 2024-07-26 00:00:22 +08:00
MaysWind a86428cc4d remove unused code 2024-07-25 22:39:50 +08:00
MaysWind efce4cc04e remove clear button 2024-07-25 01:07:04 +08:00
MaysWind a8484cfcaf remove display password button 2024-07-25 01:03:25 +08:00
MaysWind cc55b98e80 PIN code input supports Home & End keys and does not process f1-f12 keys and alt key 2024-07-25 00:51:19 +08:00
MaysWind 0620194c78 support showing hidden categories in filtering page / dialog 2024-07-25 00:43:21 +08:00
MaysWind a00a67f3d1 set select button disabled when there are no visible items 2024-07-25 00:18:26 +08:00
MaysWind 6b9ad1a1c8 set display transaction tags in transaction list page by default 2024-07-24 23:52:15 +08:00
MaysWind 3d0b993c45 modify style 2024-07-24 23:49:43 +08:00
MaysWind dc74ac0d0b code refactor 2024-07-24 23:48:19 +08:00
MaysWind 7ed923b347 hide sub accounts whose parent account is hidden 2024-07-24 23:36:56 +08:00
MaysWind 1f63aa8cdf show no available accounts when all accounts are hidden 2024-07-24 23:30:53 +08:00
MaysWind 84f7eab95d show no available tags when all tags are hidden 2024-07-24 23:30:33 +08:00
MaysWind 7d6c7f49e5 support showing hidden accounts in filtering page / dialog 2024-07-24 01:16:07 +08:00
MaysWind 021e523d63 support showing hidden tags in filtering page / dialog 2024-07-24 01:16:00 +08:00
MaysWind 266dafa4a9 add comment 2024-07-23 23:50:04 +08:00
MaysWind 579c903398 not allow to add / modify / delete transaction with account whose parent account is hidden 2024-07-23 23:49:53 +08:00
MaysWind 5d2e880bc5 not allow to add / modify / delete transaction with parent account 2024-07-23 23:18:34 +08:00
MaysWind 8085f7cf11 modify style 2024-07-23 01:34:35 +08:00
MaysWind aea4cf7e8b fix the bug that hidden tags don't display in transaction detail page 2024-07-23 01:12:31 +08:00
MaysWind 3d56bfa114 not allow to add transaction with hidden transaction tag 2024-07-23 00:53:31 +08:00
MaysWind a7280bf7ed not allow to add transaction with hidden transaction category 2024-07-23 00:47:19 +08:00
MaysWind 085f9817fc not allow to set hidden account as default account 2024-07-23 00:28:20 +08:00
MaysWind fcca77bca5 auto choose the first non-hidden category when opening transaction create page / dialog 2024-07-23 00:18:44 +08:00
MaysWind 4bf2e94a9d hide categories which are hidden in transaction edit page / dialog 2024-07-23 00:18:28 +08:00
MaysWind 95c2494545 code refactor 2024-07-23 00:18:18 +08:00
MaysWind 7662e0eb02 list sheet, tree view sheet and two column select components support hidden field 2024-07-23 00:17:45 +08:00
MaysWind 9f438dd648 fix the bug that the hidden accounts, categories or tags are not displayed in the filter menu when they are chosen in transaction list page 2024-07-22 00:50:36 +08:00
MaysWind 08a1f3a5f7 code refactor 2024-07-22 00:35:36 +08:00
MaysWind 26d77de67a add transaction tag filter to frontend 2024-07-22 00:34:37 +08:00
MaysWind 4f9ab9db75 code refactor 2024-07-21 23:35:17 +08:00
MaysWind 0b83518921 update comments 2024-07-21 20:56:46 +08:00
MaysWind 0f8de8d699 custom map tile server supports annotation layer 2024-07-21 20:50:49 +08:00
MaysWind aae23c285e map provider supports TianDiTu 2024-07-21 20:00:57 +08:00
MaysWind daf73dc964 code refactor 2024-07-21 18:59:00 +08:00
MaysWind bc0893b518 add comment 2024-07-21 18:37:32 +08:00
MaysWind a87bda09f7 modify the calculation strategy of month total amount 2024-07-21 17:45:42 +08:00
MaysWind aa7652279e remove unused parameter 2024-07-21 17:04:47 +08:00
MaysWind d86dea4081 modify hint 2024-07-16 00:39:49 +08:00
MaysWind 15d476fd40 modify log content prefix 2024-07-16 00:32:32 +08:00
MaysWind 453a9ff61c code refactor 2024-07-16 00:18:43 +08:00
MaysWind 26e9a0ef2a only show chart data type of categorical analysis in statistics setting page 2024-07-15 01:25:33 +08:00
MaysWind 7849b2f05c update url address when changing the settings on the statistics analysis page 2024-07-15 01:13:45 +08:00
MaysWind 2cbcc40ca9 fix the bug that the overview amounts in home page would not update after changing the first day of week 2024-07-14 18:15:41 +08:00
MaysWind 184dad8185 code refactor 2024-07-14 18:14:08 +08:00
MaysWind 46a7cd441f code refactor 2024-07-14 18:07:13 +08:00
MaysWind 55249e07a3 support setting token min refresh interval 2024-07-14 17:47:35 +08:00
MaysWind d4850b4a18 fix the bug that the first day of week setting does not take effect in the transaction list page in desktop version 2024-07-14 16:59:07 +08:00
MaysWind 93819d5894 check whether the setting value is valid and modify the allowed minimum value of settings 2024-07-14 16:15:11 +08:00
MaysWind 4a4cec3d69 modify default token expired time in code 2024-07-14 15:32:28 +08:00
MaysWind 432993121c fix the bug that cannot use multiple sessions to access at the same time after the application lock is enabled 2024-07-14 14:26:08 +08:00
MaysWind 1ce0c62c30 update local expense / income amount color settings 2024-07-14 11:09:09 +08:00
MaysWind b1343ba92a support setting expense / income amount color 2024-07-14 01:00:14 +08:00
MaysWind 84a96d80b7 support showing transaction tag in transaction list page 2024-07-10 01:08:21 +08:00
MaysWind 4b249a0ebb code refactor 2024-07-09 22:40:53 +08:00
MaysWind 58de308f30 modify variable name 2024-07-09 22:23:26 +08:00
MaysWind f151eb6197 add command transaction-tag-index-fix-transaction-time to fix transaction tag index which does not have transaction time 2024-07-09 00:53:22 +08:00
MaysWind 9eaac329b9 modify log 2024-07-09 00:25:35 +08:00
MaysWind a33123022f command transaction-check supports checking whether transaction tag index has transaction time 2024-07-09 00:25:13 +08:00
MaysWind 3eac9af403 code refactor 2024-07-09 00:06:18 +08:00
MaysWind a371058096 fix the bug that the transaction time was not correctly saved into the transaction tag index table 2024-07-08 23:54:23 +08:00
MaysWind ee003160e5 update comment 2024-07-08 01:16:24 +08:00
MaysWind 847349dcbd support using duplicate checker to prevent duplicate submissions for new transaction record 2024-07-08 01:10:04 +08:00
MaysWind a2d6aff28b show warning log when secret_key is not set 2024-07-07 21:27:41 +08:00
MaysWind cc3e1f2978 code refactor 2024-07-07 17:28:25 +08:00
MaysWind dc9bf1a1d8 modify date format 2024-07-07 17:22:44 +08:00
MaysWind 32af32d02e code refactor 2024-07-07 17:07:13 +08:00
MaysWind d91c99c177 upgrade third party dependencies 2024-07-07 17:07:08 +08:00
MaysWind d0e8419b2e use the account / transaction category filter of the statistics page when navigating from the statistics page to the transaction list page 2024-07-07 14:37:20 +08:00
MaysWind dad3f1041e fix the bug that the filter does not take effect when navigating from the account or statistics page to the transaction list page 2024-07-07 14:08:53 +08:00
MaysWind 7b70b2db29 only show categories with specified type in category filter dialog / page 2024-07-07 13:37:56 +08:00
MaysWind e5a04596e1 modify style 2024-07-07 11:35:31 +08:00
MaysWind 297f8b9987 do not reload transaction list when filter is not changed actually 2024-07-07 11:18:43 +08:00
MaysWind 9eddab3dd8 modify style 2024-07-07 10:38:47 +08:00
MaysWind ec97d2df91 remove unused code 2024-07-07 10:18:53 +08:00
MaysWind 3dd39defc1 support filter multiple accounts and categories in transaction list page 2024-07-07 01:33:22 +08:00
MaysWind c0cc9b5247 code refactor 2024-07-06 21:10:52 +08:00
MaysWind dc13afc071 code refactor 2024-07-06 21:02:49 +08:00
MaysWind 628bd48f73 code refactor 2024-07-05 01:25:51 +08:00
MaysWind c827675e14 code refactor 2024-07-04 23:48:41 +08:00
MaysWind 09fb474921 code refactor 2024-07-04 23:41:02 +08:00
MaysWind 7e1338e081 fix the bug that there are duplicate transaction in transaction list response when filtering multiple accounts 2024-07-04 00:55:41 +08:00
MaysWind 7a5d7337cd code refactor 2024-07-04 00:15:32 +08:00
MaysWind b80041433c transaction list api supports filtering by multiple account / category 2024-07-03 00:21:56 +08:00
MaysWind dd32ab83cb modify style 2024-07-02 22:34:48 +08:00
MaysWind c8db448dfc fix the bug that the page cannot be loaded when clicking the date which is already chosen 2024-07-02 01:20:38 +08:00
MaysWind 51edca66d1 add more currency display type 2024-07-02 01:10:34 +08:00
MaysWind bb5529098f add comment 2024-07-02 01:07:14 +08:00
MaysWind d5a54dd1fb code refactor 2024-07-02 01:03:40 +08:00
MaysWind 329119fc3b modify text 2024-07-02 00:46:22 +08:00
MaysWind e43cf26bb5 code refactor 2024-07-01 23:27:34 +08:00
MaysWind 93bc3bf94b code refactor 2024-07-01 23:03:55 +08:00
MaysWind 675b5f039a remove unused code 2024-07-01 22:57:07 +08:00
MaysWind b3e4e39b49 code refactor 2024-07-01 01:00:55 +08:00
MaysWind 4dc072f56d code refactor 2024-07-01 00:53:29 +08:00
MaysWind b5d72c89f2 support filtering transaction amount 2024-07-01 00:48:00 +08:00
MaysWind d2b3900ed4 code refactor 2024-06-30 21:18:21 +08:00
MaysWind 4e44553c07 modify style 2024-06-30 18:13:36 +08:00
MaysWind ec6b5fb155 move type filter to more filter popover menu 2024-06-30 17:51:00 +08:00
MaysWind 16e53feaf4 fix the bug that cannot view the location on the map of an existing transaction in desktop page 2024-06-30 16:43:58 +08:00
MaysWind 182bdb34cd code refactor 2024-06-30 16:25:39 +08:00
MaysWind 59d03b54d7 move currency display type to user settings 2024-06-30 16:25:32 +08:00
MaysWind 445969c449 remove unused code 2024-06-30 12:05:41 +08:00
MaysWind 29214a3bf3 remove unused code 2024-06-30 01:52:52 +08:00
MaysWind d979dc3535 code refactor 2024-06-30 01:52:20 +08:00
MaysWind 399413a270 support setting decimal separator and digit grouping symbol 2024-06-30 01:48:36 +08:00
MaysWind d9c8142c51 code refactor 2024-06-29 17:12:02 +08:00
MaysWind c951285049 modify http response code 2024-06-29 16:15:14 +08:00
MaysWind 0e372969b3 fix wrong text 2024-06-29 15:17:36 +08:00
MaysWind 2d51f7b2be show provider of exchange rates data and map in about page 2024-06-29 14:09:47 +08:00
MaysWind 02a5dcf9ba fix the bug that the transaction view dialog does not show no location 2024-06-29 11:51:39 +08:00
MaysWind 9a70cfe24c modify style 2024-06-29 11:46:12 +08:00
MaysWind 023d875a00 modify style 2024-06-29 11:13:25 +08:00
MaysWind 640f74c612 add keyword parameters to the URL when searching for transaction descriptions in the desktop transaction list 2024-06-29 10:43:20 +08:00
MaysWind 768b005200 only show the types of categorical analysis 2024-06-28 22:24:08 +08:00
MaysWind fb315127f9 fix the bug that the frontend would not display any secondary transaction categories after modifying the primary transaction category 2024-06-24 00:51:57 +08:00
MaysWind 756e6cac5a update currency name 2024-06-24 00:42:10 +08:00
MaysWind daae5b68cd hide trend analysis settings in statistics settings page 2024-06-24 00:23:54 +08:00
MaysWind 0e391bee50 support changing primary category for transaction category 2024-06-24 00:21:47 +08:00
MaysWind 9627e65d6d display items in specified sorting type in tooltip for trend analysis chart 2024-06-23 22:29:47 +08:00
MaysWind 830974500b modify style 2024-06-23 21:56:54 +08:00
MaysWind 0438964e17 add seconds to time column in exported data 2024-06-17 00:30:15 +08:00
MaysWind 226d44651f code refactor 2024-06-17 00:22:32 +08:00
MaysWind db71ac5279 remove unused code 2024-06-17 00:20:04 +08:00
MaysWind 7e2a0b1483 set default trends date range type to this year 2024-06-10 23:48:56 +08:00
MaysWind a219444953 trends analysis supports total expense / total income / total balance 2024-06-10 23:42:41 +08:00
MaysWind 5fec41055e keep the state of selected legend 2024-06-10 23:26:04 +08:00
MaysWind 5107e451d8 hide trends chart when no transaction data 2024-06-10 22:35:45 +08:00
MaysWind 3b34cdbda2 remove unused code 2024-06-10 22:26:57 +08:00
MaysWind 35fcd32a96 show total amount on tooltip for trend chart 2024-06-10 22:19:31 +08:00
MaysWind cf325b4267 auto change y-axis width based on label width 2024-06-10 22:02:30 +08:00
MaysWind 860ef610b7 modify style 2024-06-10 22:00:55 +08:00
MaysWind 5c5e6a60a7 update README.md 2024-06-09 23:13:26 +08:00
MaysWind 435c38fb27 fix the bug that the build.bat scripts would not immediately stop when a part of the build fails 2024-06-09 23:13:16 +08:00
MaysWind d640b7a5f5 modify style 2024-06-09 23:13:07 +08:00
MaysWind ae25b4eb4e modify style 2024-06-09 23:12:52 +08:00
MaysWind 315a686fed upgrade third party dependencies 2024-06-09 23:12:36 +08:00
MaysWind 4dd79b07d9 fix the bug that cannot set the date range automatically when navigate to transaction list page by clicking category in statistics page in mobile version 2024-06-09 02:35:59 +08:00
MaysWind e88d803232 add trends analysis chart 2024-06-09 02:31:13 +08:00
MaysWind 1489854444 Fix the bug that no transaction would display when all date range in categorical analysis is selected 2024-06-09 01:30:41 +08:00
MaysWind c5f9e276b4 modify style 2024-06-09 00:57:02 +08:00
MaysWind b2a8b359d1 code refactor 2024-06-08 23:05:55 +08:00
MaysWind 72f6a9e9a3 use the preset name if custom date range matches a preset item 2024-06-03 00:47:39 +08:00
MaysWind 809172bf34 add date filter for trend analysis 2024-06-03 00:30:25 +08:00
MaysWind c34887240e trend analysis supports data from all dates 2024-06-02 22:06:52 +08:00
MaysWind f041e7cb7d add chart date type settings for trend analysis 2024-05-27 01:36:46 +08:00
MaysWind 5eca777891 add chart type and chart data type settings for trend analysis 2024-05-26 23:58:26 +08:00
MaysWind a9e3b79eb1 fix the bug that the selected date range in date selection dialog would not change to current set range after opening the date selection dialog in desktop version 2024-05-26 22:57:57 +08:00
MaysWind 0a376217c6 upgrade third party dependencies 2024-05-26 22:00:01 +08:00
MaysWind 687d062bfc modify style 2024-05-26 20:25:18 +08:00
MaysWind e123498895 code refactor 2024-05-26 19:49:04 +08:00
MaysWind a917d16c26 code refactor 2024-05-26 19:48:40 +08:00
MaysWind 0884af038d add trend analysis api 2024-05-20 00:01:40 +08:00
MaysWind 72619f3dad upgrade golang to 1.21.9, node.js to 18.20.2 2024-05-19 17:02:51 +08:00
MaysWind cf120dbcbf fix the bug that cannot load more transaction after opening and clicking cancel button custom time range dialog 2024-05-19 16:57:23 +08:00
MaysWind 9906f1b1a7 fix the bug that the "Date" is highlighted after custom date sheet opened even the date range is not set in mobile transaction list page 2024-05-01 16:40:29 +08:00
MaysWind ea32bfa5fc support setting timezone type for the time range of statistical data 2024-04-13 23:31:24 +08:00
MaysWind 14f6de8af1 remove unused code 2024-04-06 23:58:22 +08:00
MaysWind 176d5a15c5 show "-dev" after the version number when the build version is not a release version 2024-04-06 17:05:04 +08:00
MaysWind cfc7a5bd49 modify style 2024-04-06 16:37:48 +08:00
MaysWind 4b68ccf678 update text content 2024-04-06 16:33:08 +08:00
MaysWind cb57b216d0 fix the bug that the sheet would display blank after clicking now button and switching from date mode to time mode in date time selection sheet 2024-04-05 23:45:33 +08:00
MaysWind 222951139c swap the location of now and switch mode button 2024-04-05 23:33:52 +08:00
MaysWind 821c7bfbd3 modify style 2024-04-05 23:24:06 +08:00
MaysWind 722c1d7917 fix missing go.sum entry 2024-04-05 23:06:42 +08:00
MaysWind 4d065bc724 fix the bug that the status of email changes to unverified after clicking reset button in the user basic setting page 2024-04-05 22:57:48 +08:00
MaysWind 2a2cb3acc9 modify style 2024-04-05 22:45:06 +08:00
MaysWind 4a16b82700 upgrade Materio to 2.2.1 2024-04-05 00:19:48 +08:00
MaysWind ea97f8cf7a update third party dependencies 2024-04-04 21:16:40 +08:00
MaysWind 28f113d992 code refactor 2024-04-04 20:44:37 +08:00
MaysWind 46caf46ef7 sort languages by language code 2024-03-30 15:41:34 +08:00
MaysWind 185758b638 use current platform to build frontend assets 2024-03-25 00:30:56 +08:00
MaysWind 1e047aed80 code refactor 2024-03-24 17:41:17 +08:00
MaysWind 8ef7676b4f modify text 2024-03-24 15:41:52 +08:00
MaysWind b2fc24d5ae add geographic location to exported file 2024-03-24 15:38:09 +08:00
MaysWind 065cd9dff8 modify text 2024-03-24 15:14:13 +08:00
MaysWind 532c762553 code refactor 2024-03-24 13:54:13 +08:00
MaysWind 4857055eaf modify text 2024-03-24 13:44:57 +08:00
MaysWind 604379bf85 modify style 2024-03-24 13:35:29 +08:00
MaysWind 3d0f793cf6 code refactor 2024-03-24 01:41:42 +08:00
MaysWind 9f8634ac11 add date navigation button in desktop version transaction list page 2024-03-24 01:40:55 +08:00
MaysWind 6ab70b97b4 move all date to the top of navigation bar 2024-03-24 01:16:07 +08:00
MaysWind 8c164ec833 modify style 2024-03-24 01:09:42 +08:00
MaysWind 09d47459b6 add toggle calendar and time picker button 2024-03-10 20:48:20 +08:00
MaysWind 2916106f27 auto set destination amount by source amount when destination amount is zero 2024-03-10 19:55:29 +08:00
MaysWind 6b4292596a add date navigation button in mobile version transaction list page 2024-03-10 19:05:55 +08:00
MaysWind 78aebb7d6c show the transaction time in default timezone on tooltip in desktop version transaction list page when the transaction time zone is not the default time zone 2024-03-10 13:45:21 +08:00
MaysWind 771bb35e9f show the transaction time in default timezone by clicking the transaction time in mobile version transaction view page when the transaction time zone is not the default time zone 2024-03-10 13:45:08 +08:00
MaysWind 453ed4227d modify style 2024-03-10 13:44:41 +08:00
MaysWind 88a284a21b use default cursor when input is readonly 2024-03-10 11:52:50 +08:00
MaysWind 9488b85705 fix the bug that the time picker for mobile device displays and sets incorrectly when the default time zone is not the browser time zone 2024-03-10 02:34:28 +08:00
MaysWind 8be3fd7ed4 remove unused import 2024-03-10 01:15:10 +08:00
MaysWind 8fdc0019ea fix the bug that the text size of action menu title label does not follow the app text size change 2024-03-09 23:02:25 +08:00
MaysWind b21dd73ff2 fix default time is wrong in add transaction dialog when default time zone is not the browser time zone in desktop version 2024-03-09 22:49:07 +08:00
MaysWind 24432701dd modify style 2024-03-09 22:17:28 +08:00
MaysWind 802cdabd75 the order of year and month in the date picker is based on the order in long date format set by the user 2024-03-09 21:51:23 +08:00
MaysWind 8c83af1543 add swap account and/or amount menu in transaction edit page 2024-03-04 00:28:20 +08:00
MaysWind deb465377e show exchange rate in transaction edit page when the currencies of source account and destination account are different 2024-03-04 00:09:21 +08:00
MaysWind bed2aef7f8 use time picker in datetime selection sheet for mobile device 2024-03-03 23:01:27 +08:00
MaysWind 57b2b2492f fix cannot build frontend on arm/v6 or arm/v7 platform 2024-03-03 18:52:14 +08:00
MaysWind 87cdaee547 update base image and go/node version 2024-03-03 16:58:05 +08:00
MaysWind a8b0ef2483 update third party dependencies 2024-03-03 14:06:36 +08:00
MaysWind 63e976e5cb update third party dependencies 2024-03-03 13:33:28 +08:00
MaysWind f73830d37b bump year to 2024 2024-03-03 13:19:48 +08:00
MaysWind 6511c4e810 update exchange rates data api of National Bank Of Poland 2024-03-03 12:12:11 +08:00
MaysWind 008c58f52b use custom user-agent to request exchange rates data 2024-03-03 11:51:58 +08:00
MaysWind fa4a17f47b support setting proxy to request exchange rates or map data 2024-03-03 11:46:30 +08:00
MaysWind 3fc2a763b4 set navigation button disabled when reloading 2023-12-04 00:23:53 +08:00
MaysWind 1b2726a55c update third party dependencies 2023-12-03 20:47:59 +08:00
MaysWind 229fa27b73 modify style 2023-11-19 22:12:09 +08:00
f97 072f44245d typo: third-party-dependencies.json 2023-11-01 21:55:54 +08:00
MaysWind dc837c430f support export to tsv file 2023-10-29 21:05:17 +08:00
MaysWind 429e270a9e modify x-small chip style 2023-10-29 16:33:55 +08:00
MaysWind 985cefc297 update third party dependencies 2023-10-29 16:28:36 +08:00
MaysWind 285fc0ced3 map provider supports CartoDB 2023-10-29 16:18:52 +08:00
MaysWind 777e14b6d4 show error when request file name extension is invalid when using map image proxy 2023-10-29 14:54:58 +08:00
MaysWind d18e8211ca show error when specified map provider is not current provider when using map image proxy 2023-10-29 14:47:18 +08:00
MaysWind 2984980a54 code refactor 2023-10-29 14:39:44 +08:00
MaysWind 2302f31f18 set min zoom level to leaflet control 2023-10-29 14:16:47 +08:00
MaysWind f673677c2a support custom map tile server url 2023-10-29 14:09:51 +08:00
MaysWind acc9bf77fb update third party dependencies 2023-10-16 00:29:51 +08:00
MaysWind 678769da0e code refactor 2023-10-16 00:20:00 +08:00
MaysWind 2389208c95 optimize service worker config 2023-10-09 21:48:41 +08:00
MaysWind 28a1080ccc fix redundant code 2023-09-26 01:00:54 +08:00
MaysWind 432d3fd3c3 add missing files when building package 2023-09-26 00:57:56 +08:00
MaysWind 8fc73b866b fix the issue of checking the result code 2023-09-26 00:52:39 +08:00
MaysWind dbc62aac93 fix unit test failure 2023-09-25 23:37:16 +08:00
MaysWind 79802a46fd add build script for windows 2023-09-25 01:28:38 +08:00
MaysWind 3d8ecb42c1 change disabled column nullable 2023-09-24 22:34:00 +08:00
MaysWind bcaf554ae4 navigate to the home page immediately after sign up successfully 2023-09-18 01:41:35 +08:00
MaysWind 3ac4a921e0 update vue-datepicker to 6.1.0 2023-09-18 01:27:54 +08:00
MaysWind 128f86b643 remove redundant code 2023-09-18 01:27:15 +08:00
MaysWind 828fc690b6 bump version to 0.5.0 2023-09-18 01:11:04 +08:00
MaysWind 55d06640b5 auto hide forget password sheet after email has been resent 2023-09-17 19:37:26 +08:00
MaysWind 3874a8da21 clear email when open forget password sheet 2023-09-17 19:36:36 +08:00
MaysWind 6a3ecd5b09 add cancel button to forget password sheet 2023-09-17 19:34:20 +08:00
MaysWind afcd4f7262 support resending verify mail on mobile device 2023-09-17 19:32:12 +08:00
MaysWind b0ae731fa6 update golang to 1.20.8 2023-09-17 18:12:55 +08:00
MaysWind 0b678fe69a code refactor 2023-09-17 18:07:09 +08:00
MaysWind 4cecc78a74 don't show verify email has sent to when there are no valid verify tokens 2023-09-17 18:01:10 +08:00
MaysWind 92273d2fc6 add skip_tls_verify option for exchange rates 2023-09-17 17:24:41 +08:00
MaysWind 04ec749c3c fix the bug that old uuid may be generated sometimes 2023-09-16 23:53:45 +08:00
MaysWind 165377816c return error when uuid is not enough 2023-09-16 22:45:00 +08:00
MaysWind 729904e1c3 remove unused code 2023-09-15 21:54:25 +08:00
MaysWind 6bc4fa0a82 update vuetify to 3.3.16 2023-09-15 21:41:28 +08:00
MaysWind f12403672b update README 2023-09-10 23:30:44 +08:00
MaysWind 97daff8d4f update mobile screenshot img url 2023-09-10 22:12:36 +08:00
MaysWind a32451fd7f hide resend verify email button when server disables verify email 2023-09-10 17:29:10 +08:00
MaysWind ca14770971 return error entity when verify email is not enabled 2023-09-10 17:25:23 +08:00
MaysWind 35ac0695c7 don't show send mail tips when force verify email is enabled but verify email is not enabled 2023-09-10 17:15:56 +08:00
MaysWind 589b614a53 remove old email verify token before send new verify email when email changed 2023-09-10 17:00:19 +08:00
MaysWind 64ea3e05d8 aysnc send email 2023-09-10 16:49:24 +08:00
MaysWind 5d0e115438 auto send verify email when user email has been changed 2023-09-10 16:40:48 +08:00
MaysWind 9f35c1eded improve user registration page 2023-09-10 16:21:29 +08:00
MaysWind ff07346fe9 modify style 2023-09-10 00:25:52 +08:00
MaysWind 22fffc2f8c profile page supports resending verify email 2023-09-10 00:25:42 +08:00
MaysWind 205363dd42 change link text when email is verified 2023-09-09 21:33:17 +08:00
MaysWind d2297b882f send verify email after account has been registered 2023-09-09 21:28:17 +08:00
MaysWind 48bf8dbc5b don't create temporary token when smtp is not enabled 2023-09-09 21:25:30 +08:00
MaysWind 9585c760d5 modify style 2023-09-09 16:39:28 +08:00
MaysWind 8c2cd0aa4d modify style 2023-09-09 15:56:21 +08:00
MaysWind 2e680b04c9 supports building for different platforms for gitea actions 2023-09-04 23:33:52 +08:00
MaysWind e2b81f7b57 add email verification 2023-09-04 23:30:33 +08:00
MaysWind c38b277887 disabled user cannot use forget password 2023-09-03 23:35:01 +08:00
MaysWind a1f6304b22 fix log content is wrong when content has % symbol 2023-09-03 23:15:40 +08:00
MaysWind 09d7f56efc modify log formatter 2023-09-03 13:56:19 +08:00
MaysWind 9221f3fc96 add request id to sql query log 2023-09-03 13:54:07 +08:00
MaysWind 6b30a0aebc supports building multiple image by gitea actions 2023-09-02 19:33:48 +08:00
MaysWind 158563f387 code refactor 2023-09-02 00:05:31 +08:00
MaysWind b0fc5752e2 upgrade vuetify to 3.3.15 2023-09-01 23:28:18 +08:00
MaysWind 28903615ed fix the problem that unit test would not run sometimes 2023-08-28 23:06:03 +08:00
MaysWind caa83c0432 modify text 2023-08-28 00:16:16 +08:00
MaysWind 045f4a42db replace the app name in the email with the one configured in the config file 2023-08-28 00:11:42 +08:00
MaysWind 3275bc9cae redirect page to login page after reset password successfully 2023-08-27 23:58:00 +08:00
MaysWind 6d14eaefe1 code refactor 2023-08-27 23:51:26 +08:00
MaysWind 03274725be modify text and field name 2023-08-27 23:28:38 +08:00
MaysWind 0951006063 add updating user email verified state utility 2023-08-27 22:54:34 +08:00
MaysWind 616bfc6a2a not allow send password reset mail when email address is not verified 2023-08-27 22:35:16 +08:00
MaysWind c0bfe429ee the language of password reset email set to client language if user language is not set 2023-08-27 22:29:54 +08:00
MaysWind c1d90485a1 reset password don't set authorization header 2023-08-27 22:21:10 +08:00
MaysWind 6c30527684 fix typo 2023-08-27 21:37:22 +08:00
MaysWind 4ac751f492 add send user password reset email command line utility 2023-08-27 21:37:10 +08:00
MaysWind de7b137257 add send test email utility 2023-08-27 21:26:30 +08:00
MaysWind 0bf689fa8d code refactor 2023-08-27 21:23:03 +08:00
MaysWind f31ef1649f support reset password by email reset link 2023-08-27 21:22:52 +08:00
MaysWind c66bc62c41 code refactor 2023-08-26 23:35:53 +08:00
MaysWind 9ee59215c8 code refactor 2023-08-26 23:14:42 +08:00
MaysWind a991adecaf modify style 2023-08-26 19:36:39 +08:00
MaysWind 601b1d5c89 modify style 2023-08-26 19:10:06 +08:00
MaysWind 8b593883a7 modify style 2023-08-26 18:22:19 +08:00
MaysWind bb549c9a89 modify text 2023-08-26 17:57:42 +08:00
MaysWind 9f2005622a fix color is wrong in dark theme 2023-08-26 17:11:29 +08:00
MaysWind 4238bde13a modify style 2023-08-26 00:21:00 +08:00
MaysWind 2d4ce1aac0 modify style 2023-08-26 00:12:29 +08:00
MaysWind 37269abde2 update go.sum 2023-08-26 00:02:05 +08:00
MaysWind 54038eabd4 upgrade vuetify to 3.3.14 2023-08-26 00:01:12 +08:00
MaysWind 57922e3071 modify style 2023-08-25 23:59:05 +08:00
MaysWind ad36dfd448 auto choose the secondary category of selected primary category when create transaction in transaction list page 2023-08-22 00:57:02 +08:00
MaysWind 6e334d2efb hide the category dropdown menu when click the item 2023-08-22 00:05:36 +08:00
MaysWind d3dc1401fd auto hide the dropdown menu when click the menu item 2023-08-21 23:59:48 +08:00
MaysWind 0f59c9911d modify style 2023-08-21 23:54:36 +08:00
MaysWind 0ebfd2bc76 modify style 2023-08-21 23:51:24 +08:00
MaysWind 734625c1e3 modify style 2023-08-21 23:46:34 +08:00
MaysWind eaaea8a64f modify props and fields name 2023-08-21 23:42:07 +08:00
MaysWind c026ab1777 modify style 2023-08-21 23:41:10 +08:00
MaysWind 2c7193efea don't allow show the transaction detail when the transaction type is modify balance 2023-08-21 23:35:59 +08:00
MaysWind 21f5ef469b always display account name even if the account is hidden 2023-08-21 23:35:21 +08:00
MaysWind e7f9eb6e06 modify style 2023-08-21 23:25:15 +08:00
MaysWind 9db9c382ab modify text 2023-08-21 23:16:12 +08:00
MaysWind b2ba626cde hide the buttons which is not supported 2023-08-21 23:15:45 +08:00
MaysWind 85d93c4f4b auto hide the dropdown menu when click the secondary item 2023-08-21 23:02:46 +08:00
MaysWind 09f6dd8d82 add more log in unit test 2023-08-21 01:41:03 +08:00
MaysWind 2aa6df48c6 remove unused code 2023-08-21 01:16:43 +08:00
MaysWind 2a16260b05 transaction edit dialog supports duplicate transaction, edit transaction and save new transaction 2023-08-21 01:11:01 +08:00
MaysWind c28158b041 remove unused code 2023-08-21 00:00:25 +08:00
MaysWind 661199850c remove unused code 2023-08-20 23:53:41 +08:00
MaysWind 67b69a45cc modify style 2023-08-20 23:33:14 +08:00
MaysWind 579322578b modify style 2023-08-20 22:21:50 +08:00
MaysWind 0de65042b0 transaction edit dialog supports transaction category 2023-08-20 20:31:29 +08:00
MaysWind f9643f8651 modify method name 2023-08-20 19:43:22 +08:00
MaysWind fc9ac4c40e modify method name 2023-08-20 19:41:51 +08:00
MaysWind 84843066f2 fix style 2023-08-20 19:23:13 +08:00
MaysWind af3d5c4654 remove unused code 2023-08-20 17:35:11 +08:00
MaysWind 676f3daf50 modify style 2023-08-20 17:30:47 +08:00
MaysWind 54970015cb update third party dependencies 2023-08-20 17:17:41 +08:00
MaysWind 2b49430530 update golang to 1.20.7, update node.js to 18.17.1, update alpine base image to 3.17.5 2023-08-20 17:01:01 +08:00
MaysWind feea5f3518 modify style 2023-08-20 16:57:00 +08:00
MaysWind 04e580b40f modify style 2023-08-20 01:22:00 +08:00
MaysWind f07a3ba97d code refactor 2023-08-20 01:09:23 +08:00
MaysWind f44e3a81ab modify style 2023-08-20 01:08:26 +08:00
MaysWind 77d3bd019e fix the problem that the thousands separator is missing 2023-08-20 00:57:01 +08:00
MaysWind 72725d5ab4 hide set as base currency when the currency is already the base 2023-08-20 00:54:11 +08:00
MaysWind aa717ed1fe fix the problem that the time zone of the modify balance transaction generated by creating a new account was wrong 2023-08-20 00:48:42 +08:00
MaysWind 292b49ba79 modify style 2023-08-20 00:40:56 +08:00
MaysWind c600eb5d5a modify style 2023-08-20 00:33:57 +08:00
MaysWind 6d2f788fa2 code refactor 2023-08-20 00:30:55 +08:00
MaysWind b8acff3e7c code refactor 2023-08-20 00:30:30 +08:00
MaysWind b8bdb03fc0 transaction edit dialog supports transaction time 2023-08-19 22:41:13 +08:00
MaysWind 7257abefb4 fix AM/PM text in datetime picker is not translated 2023-08-19 22:37:26 +08:00
MaysWind 3095613a76 transaction edit dialog supports transaction tags 2023-08-18 00:44:05 +08:00
MaysWind db12b64b3a modify style 2023-08-18 00:03:49 +08:00
MaysWind a081edde25 modify style 2023-08-17 23:01:52 +08:00
MaysWind 5496c4a10a code refactor 2023-08-16 00:51:12 +08:00
MaysWind c968ded99a transaction edit dialog supports map 2023-08-16 00:51:03 +08:00
MaysWind 4224a833b4 code refactor 2023-08-16 00:04:50 +08:00
MaysWind df470f1a5e modify style 2023-08-15 22:53:09 +08:00
MaysWind ed0100a82c add more transaction edit dialog basis code 2023-08-15 01:05:59 +08:00
MaysWind 0ad72e8334 modify style 2023-08-14 23:13:03 +08:00
MaysWind 50ee5d1f49 hide control buttons when loading 2023-08-14 23:12:35 +08:00
MaysWind 94283a8da2 update unit test 2023-08-14 22:47:54 +08:00
MaysWind 86e9a3e838 add transaction edit dialog basis code 2023-08-14 01:03:43 +08:00
MaysWind d77b9ef7c9 code refactor 2023-08-14 00:55:41 +08:00
MaysWind e29afa3155 code refactor 2023-08-14 00:14:53 +08:00
MaysWind 7376fbe7a1 code refactor 2023-08-13 23:58:28 +08:00
MaysWind 04e98e1c39 code refactor 2023-08-13 23:40:29 +08:00
MaysWind ddca6e7ec9 code refactor 2023-08-13 23:33:07 +08:00
MaysWind 1ac12ac668 remove unused code 2023-08-13 23:03:30 +08:00
MaysWind 399936e3ab code refactor 2023-08-13 22:58:52 +08:00
MaysWind 3d086992dc modify style 2023-08-13 22:52:31 +08:00
MaysWind 62ded1290c code refactor 2023-08-13 22:19:59 +08:00
MaysWind 33cbdfbd13 code refactor 2023-08-13 20:48:32 +08:00
MaysWind f5ce6ed4bc code refactor 2023-08-13 20:32:51 +08:00
MaysWind eb9a2ac2e0 code refactor 2023-08-13 20:13:27 +08:00
MaysWind 8bed529d05 code refactor 2023-08-13 20:06:42 +08:00
MaysWind 41a8b8007a code refactor 2023-08-13 20:01:44 +08:00
MaysWind 141dc843f3 modify method name 2023-08-13 19:52:58 +08:00
MaysWind 902825404e support disable boot log 2023-08-13 18:28:08 +08:00
MaysWind 749eaaab30 add parsing request id command utility 2023-08-13 18:19:09 +08:00
MaysWind 8f5767b992 add unit test 2023-08-13 17:27:25 +08:00
MaysWind 715f0c5853 add unittest 2023-08-13 17:08:36 +08:00
MaysWind a960fd3d56 improve unit test 2023-08-13 16:52:20 +08:00
MaysWind 17b8ac6d0b fix gravatar url is invalid when email contains upper characters 2023-08-13 15:37:48 +08:00
MaysWind 4ac78fe4d1 modify method name 2023-08-13 15:30:20 +08:00
MaysWind 957bcf790f code refactor 2023-08-13 15:18:38 +08:00
MaysWind 66f0b38008 code refactor 2023-08-13 15:03:31 +08:00
MaysWind f6a2246aab code refactor 2023-08-13 14:58:11 +08:00
MaysWind fa3e941069 show login link in sign up page 2023-08-13 14:46:06 +08:00
MaysWind 9bb049f746 modify style 2023-08-13 14:21:53 +08:00
MaysWind c41f2a4d65 modify style 2023-08-13 14:20:40 +08:00
MaysWind 06ff9f2499 add add/edit account dialog 2023-08-13 02:00:14 +08:00
MaysWind f91f9fcc94 modify style 2023-08-13 01:35:11 +08:00
MaysWind 9a626b0d4f modify dialog style 2023-08-13 01:34:40 +08:00
MaysWind ac2adaf4ba modify style 2023-08-12 18:16:53 +08:00
MaysWind 3ab198615b code refactor 2023-08-12 13:05:03 +08:00
MaysWind 7c2831098c clear displayed transactions when changing filter 2023-08-12 00:28:32 +08:00
MaysWind 2454e22ea2 support setting items per page in transaction list page 2023-08-11 23:58:30 +08:00
MaysWind b1fb40ca61 modify style 2023-08-08 01:39:50 +08:00
MaysWind e403e938c3 show category comment in list 2023-08-08 01:17:59 +08:00
MaysWind 952ba1f1ea code refactor 2023-08-07 01:32:11 +08:00
MaysWind a25690c2d3 modify style 2023-08-07 01:31:34 +08:00
MaysWind 6b6b9c61d7 auto complete supports auto selected the first item by enter / tab key 2023-08-07 01:14:47 +08:00
MaysWind fcc5e522a7 modify style 2023-08-07 01:10:07 +08:00
MaysWind c33c0487cf add add/edit transaction category dialog 2023-08-07 01:00:02 +08:00
MaysWind 1753a6c247 manually set default icon color 2023-08-07 00:48:48 +08:00
MaysWind 195f513416 code refactor 2023-08-07 00:38:08 +08:00
MaysWind c6d38bb3d7 code refactor 2023-08-06 23:57:23 +08:00
MaysWind 9b2fba9600 fix the problem that not scroll to selected item in color selection sheet or icon icon selection sheet 2023-08-06 23:35:51 +08:00
MaysWind c88f6501fa don't auto hide sheet when select icon 2023-08-06 23:30:10 +08:00
MaysWind c511346160 add missing component 2023-08-06 20:42:36 +08:00
MaysWind 0b02daac1d modify file name 2023-08-06 18:58:05 +08:00
MaysWind 2390c085e4 modify style 2023-08-06 18:52:55 +08:00
MaysWind 698d94feed code refactor 2023-08-05 23:04:29 +08:00
MaysWind a9a6d39127 update vuetify to 3.3.11 2023-08-05 17:14:59 +08:00
MaysWind 395bd31898 move files 2023-08-05 16:51:34 +08:00
MaysWind 7e24492ce8 add category preset for desktop page 2023-08-04 01:00:19 +08:00
MaysWind 19d3d80013 add category preset for desktop page 2023-08-04 00:56:26 +08:00
MaysWind 8c7875d7ea modify text 2023-08-03 18:21:19 +08:00
MaysWind 110ce0d4c6 modify skeleton style 2023-08-02 00:53:12 +08:00
MaysWind ff8c57fdab add transaction category list page 2023-08-02 00:49:37 +08:00
MaysWind 54ccdc57bf modify field name 2023-08-02 00:24:59 +08:00
MaysWind 2463f06ba1 only show add default categories button when really have no category 2023-08-02 00:14:09 +08:00
MaysWind 88d5b1f98f set drag handle disabled when loading or updating 2023-08-02 00:01:12 +08:00
MaysWind 43522e9c81 add all date type range in transaction list page 2023-08-01 22:33:12 +08:00
MaysWind 6e798f739f support click in trend chart in overview page 2023-08-01 22:15:15 +08:00
MaysWind 5b5f1280af fix the problem that the transaction date not display sometimes in transaction list page 2023-08-01 21:47:26 +08:00
MaysWind 2188e8dd78 fix wrong timeout 2023-08-01 09:20:02 +08:00
MaysWind 8659e9ea37 add asset summary card in home page, add 6 more months in trend card 2023-08-01 09:19:33 +08:00
MaysWind 4cff481d61 fix the problem that the monthly total income/expense amount sometimes is wrong 2023-07-31 10:05:30 +08:00
MaysWind 6da910d8fb modify style 2023-07-31 01:22:35 +08:00
MaysWind cc08ad46e3 modify overview page loading style 2023-07-31 01:21:47 +08:00
MaysWind 5b52c1adbb show no data in trend card when there are really no data in recent 6 months 2023-07-30 23:57:47 +08:00
MaysWind a20958a89b adjust display order of expense and income 2023-07-30 23:30:05 +08:00
MaysWind dea36d4b80 add trend in income and expense card in overview page 2023-07-30 23:26:10 +08:00
MaysWind 6cb7e4caf7 modify style 2023-07-30 22:59:03 +08:00
MaysWind 6e41668b25 add more runtime caching pattern 2023-07-30 00:43:04 +08:00
MaysWind d3e1acddc5 code refactor, modify style 2023-07-30 00:26:32 +08:00
MaysWind a9c511eb2e fix the problem that cannot search the keywords which contains & symbol in the transaction list page 2023-07-29 23:02:34 +08:00
MaysWind ffef33a9fc code refactor, set category menu disabled when type is modify balance, modify style 2023-07-29 17:26:50 +08:00
MaysWind 982917ddbb set export data button disabled if no data can be exported 2023-07-29 15:39:50 +08:00
MaysWind 07406a50bb show account icon in user basic setting tab, always show default account name even if the account is hidden, set the submit button disabled when nothing has been changed 2023-07-29 15:35:55 +08:00
MaysWind 78f325e127 code refactor 2023-07-29 15:13:37 +08:00
MaysWind e1bb97a1db improve runtime caching pattern 2023-07-27 00:01:17 +08:00
MaysWind 831952806d hide hidden category in transaction list page 2023-07-26 23:37:43 +08:00
MaysWind 4c57b7a009 fix the border not show in some device 2023-07-26 23:33:01 +08:00
MaysWind 683188f67a code refactor 2023-07-24 23:36:04 +08:00
MaysWind 848e5271ab code refactor 2023-07-24 02:41:00 +08:00
MaysWind 7ca5614c44 remove unused code 2023-07-24 02:40:20 +08:00
MaysWind 70fc781a03 add transaction list page for desktop 2023-07-24 02:36:59 +08:00
MaysWind aafdbab781 fix the problem that the time zone setting did not take effect immediately 2023-07-24 02:23:19 +08:00
MaysWind 5dd0f7ea10 only add date time params to transaction list url when date range type is set to custom in transaction statistics page 2023-07-24 00:22:30 +08:00
MaysWind 9393d9105c add persistent props to date range selection dialog 2023-07-24 00:20:43 +08:00
MaysWind 2c3856be3c change url when switching tab in user/app settings page 2023-07-24 00:20:01 +08:00
MaysWind dc746a51a5 add set as baseline button to exchange rates page for desktop 2023-07-24 00:16:43 +08:00
MaysWind 0f2c9354f0 modify style and hide operation buttons when cursor not hovered on 2023-07-24 00:16:09 +08:00
MaysWind af00032ee9 check whether user is logined when entering every page 2023-07-23 12:00:18 +08:00
MaysWind 5d7e685dc4 use icon to replace symbol character 2023-07-23 01:32:55 +08:00
MaysWind bfcb79c02b modify style 2023-07-22 23:49:55 +08:00
MaysWind ebee6273b0 code refactor 2023-07-22 23:17:13 +08:00
MaysWind b45900e5bc modify text 2023-07-22 23:06:00 +08:00
MaysWind 9f0657683a modify style 2023-07-22 23:04:08 +08:00
MaysWind 35b8d8ca25 modify style 2023-07-21 00:47:13 +08:00
MaysWind 8f7095ce19 transaction tag list page supports dragging to change display order 2023-07-20 23:52:33 +08:00
MaysWind d9c8dd20e9 modify style 2023-07-20 23:29:26 +08:00
MaysWind 8dcc462a30 modify style 2023-07-20 23:16:00 +08:00
MaysWind b561948030 code refactor 2023-07-20 01:37:45 +08:00
MaysWind 2cbd8684cf account list page supports dragging to change display order 2023-07-19 02:32:10 +08:00
MaysWind 107c9fce94 fix default icon color 2023-07-19 00:24:00 +08:00
MaysWind 4c18b3e059 optimize vite build config 2023-07-18 23:52:49 +08:00
MaysWind 9960ec4d58 modify style 2023-07-18 23:15:39 +08:00
MaysWind 2a09e048e4 code refactor 2023-07-18 00:51:47 +08:00
MaysWind 9711f3ba72 modify style 2023-07-18 00:48:54 +08:00
MaysWind 0622d2b81b modify style 2023-07-18 00:46:37 +08:00
MaysWind a372d1fb60 code refactor 2023-07-18 00:41:26 +08:00
MaysWind 99e55e730e code refactor 2023-07-18 00:29:41 +08:00
MaysWind 57d1e915e6 remove unused function reference 2023-07-18 00:18:02 +08:00
MaysWind 96ad6228bd code refactor 2023-07-18 00:17:38 +08:00
MaysWind 678f9fbe87 fix wrong text 2023-07-17 23:14:35 +08:00
MaysWind 714933df56 code refactor 2023-07-17 23:13:54 +08:00
MaysWind f0ef9ad51e remove unused code 2023-07-17 23:05:48 +08:00
MaysWind 0255213934 code refactor 2023-07-17 23:02:51 +08:00
MaysWind 0ad92a999c modify style 2023-07-17 00:35:27 +08:00
MaysWind b06456d573 modify style 2023-07-17 00:13:57 +08:00
MaysWind 6f1fc2c9b4 modify field name 2023-07-17 00:00:12 +08:00
MaysWind 44a1982d87 code refactor 2023-07-16 23:57:18 +08:00
MaysWind 6b0cf5aa96 fix missing plus symbol issue when there are unsupported currencies of sub accounts 2023-07-16 23:56:56 +08:00
MaysWind 2cfac7a772 modify style 2023-07-16 23:48:04 +08:00
MaysWind 99aaf35e0b add account list page for desktop 2023-07-16 23:35:50 +08:00
MaysWind 942ed1fb55 code refactor 2023-07-16 23:12:44 +08:00
MaysWind 25f83a98e3 code refactor 2023-07-16 23:05:11 +08:00
MaysWind ed4040f2ec code refactor 2023-07-16 22:55:02 +08:00
MaysWind 41034de676 modify style 2023-07-16 18:35:30 +08:00
MaysWind 2db0f1a6c8 code refactor 2023-07-16 16:36:47 +08:00
MaysWind 6b06cc7ef5 desktop pie chart supports clicking the chart to scroll to the specified legend 2023-07-16 16:23:56 +08:00
MaysWind 2bf26212af modify transaction statistics page style 2023-07-16 15:28:43 +08:00
MaysWind 9d273c172d modify exchange rates page style 2023-07-16 15:19:44 +08:00
MaysWind 6ae3bc82bb modify style 2023-07-16 14:03:14 +08:00
MaysWind c782002274 modify style 2023-07-16 13:16:43 +08:00
MaysWind cd0906d041 update third party dependencies 2023-07-15 23:45:43 +08:00
MaysWind f936ecca33 manually get backend dependencies before lint checking 2023-07-15 23:44:48 +08:00
MaysWind 8794e3bc53 set confirm and more button disabled when there are no available items 2023-07-15 23:43:44 +08:00
MaysWind 4503e2a222 add necessary vuetify component and remove unnecessary vuetify component 2023-07-15 23:43:06 +08:00
MaysWind 015725f88c support setting account/transaction category filter for statistics page 2023-07-15 16:54:56 +08:00
MaysWind 39451f0e37 code refactor 2023-07-15 16:21:30 +08:00
MaysWind 2bb0b7faa5 modify text color 2023-07-15 16:07:41 +08:00
MaysWind 8db7c3769a remove blank line 2023-07-15 16:07:25 +08:00
MaysWind d4e32de882 item icon supports custom class 2023-07-15 01:38:34 +08:00
MaysWind db75dea9ee fix typo 2023-07-15 00:50:29 +08:00
MaysWind 489bba9c4b modify class name 2023-07-15 00:30:42 +08:00
MaysWind 4accc49d60 reload sessions after password changed 2023-07-15 00:24:47 +08:00
MaysWind 5a47cd5216 modify style 2023-07-14 23:50:59 +08:00
MaysWind 439608cf27 remove unused configuration 2023-07-14 22:46:00 +08:00
MaysWind f9df7a1b5a modify style 2023-07-11 01:21:45 +08:00
MaysWind 78c801994a fix issue after packing js file 2023-07-11 01:16:45 +08:00
MaysWind 882fd68cf9 add some transaction statistics settings 2023-07-11 01:01:11 +08:00
MaysWind 3433b73edf code refactor 2023-07-11 01:00:27 +08:00
MaysWind a235d6a8cd code refactor 2023-07-11 00:35:21 +08:00
MaysWind d76e88af21 code refactor 2023-07-11 00:30:57 +08:00
MaysWind 3f6c6c443a code refactor 2023-07-11 00:26:27 +08:00
MaysWind 13c6d406cf only navigate to transaction list page when click pie chart label in desktop version 2023-07-11 00:15:47 +08:00
MaysWind 19fea4e761 code refactor 2023-07-10 23:26:16 +08:00
MaysWind c84c96dcd1 update service worker config 2023-07-10 22:58:46 +08:00
MaysWind cdd8ccc2d4 modify image path 2023-07-10 22:46:52 +08:00
MaysWind 09210d5d40 remove switch to mobile/desktop version in unlock page 2023-07-10 00:38:22 +08:00
MaysWind 8f44a26037 code refactor 2023-07-10 00:14:33 +08:00
MaysWind 5e986b2d04 add transaction statistics page 2023-07-10 00:04:20 +08:00
MaysWind 298c0922cb fix account icon issue in transaction statistics data page 2023-07-09 21:21:19 +08:00
MaysWind dc127ea6a3 code refactor 2023-07-09 20:43:08 +08:00
MaysWind 522ed94c32 user settings and app settings page supports showing specified tab by query parameter 2023-07-09 16:35:12 +08:00
MaysWind 6edf66a599 code refactor 2023-07-09 14:22:47 +08:00
MaysWind 475ec24528 desktop page supports service worker 2023-07-09 13:20:24 +08:00
MaysWind b555af0df7 add lock application menu 2023-07-09 13:13:35 +08:00
MaysWind f90430e544 modify style 2023-07-09 12:45:40 +08:00
MaysWind 4ccb75818c add switch to mobile/desktop device in login/unlock page 2023-07-09 12:01:29 +08:00
MaysWind c5c9ed24c3 code refactor 2023-07-09 11:12:39 +08:00
MaysWind 386aa0adc1 code refactor 2023-07-09 01:07:10 +08:00
MaysWind 0b26b75699 modify style 2023-07-09 01:04:24 +08:00
MaysWind d013f67c70 itemicon supports hidden status 2023-07-09 00:43:42 +08:00
MaysWind dc7c0e61fd code refactor 2023-07-09 00:37:51 +08:00
MaysWind 89bd041d29 add transaction tag list page 2023-07-09 00:37:19 +08:00
MaysWind ac730d6086 remove number input stepper 2023-07-08 22:56:16 +08:00
MaysWind 427eaed544 code refactor 2023-07-08 20:31:41 +08:00
MaysWind 00f783c0b6 modify style 2023-07-08 20:28:15 +08:00
MaysWind 8e1e53d55e code refactor 2023-07-08 20:07:09 +08:00
MaysWind 5c9a5c13b8 modify style 2023-07-08 19:42:33 +08:00
MaysWind c1f03a8e75 modify style 2023-07-08 17:51:15 +08:00
MaysWind 1affa83620 code refactor 2023-07-08 16:42:35 +08:00
MaysWind 2aa8180113 code refactor 2023-07-08 16:16:28 +08:00
MaysWind e6001d83a4 modify style 2023-07-08 16:16:04 +08:00
MaysWind 8550c8fde9 modify style 2023-07-08 12:25:18 +08:00
MaysWind 48d9a09307 add sign up page 2023-07-08 02:59:50 +08:00
MaysWind 9d0b874488 code refactor 2023-07-07 23:48:32 +08:00
MaysWind 062a13b2c2 modify style 2023-07-07 22:53:22 +08:00
MaysWind 937f8723ed add background img for dark theme 2023-07-06 01:07:57 +08:00
MaysWind 0c5cabbd79 modify color 2023-07-06 00:56:10 +08:00
MaysWind 95d8f710d8 exclude unnecessary file from precaching files, add some files to runtime caching 2023-07-06 00:38:28 +08:00
MaysWind bb9b8b34e5 modify img path 2023-07-06 00:04:54 +08:00
MaysWind 54c1164bd7 add illustrations to login/unlock page 2023-07-05 01:27:05 +08:00
MaysWind 89c1158d95 modify style 2023-07-04 23:06:52 +08:00
MaysWind 9cae189941 modify text tip 2023-07-03 23:04:44 +08:00
MaysWind 53a31cd4c4 use variables to replace secrets 2023-07-03 23:02:17 +08:00
MaysWind d72a615481 code refactor 2023-07-03 23:01:31 +08:00
MaysWind 8dcffa80a8 exclude vendor files for desktop page 2023-07-02 23:50:57 +08:00
MaysWind 7cf53acd18 auto focus pin code input when open desktop unlock page 2023-07-02 23:38:15 +08:00
MaysWind 748cf68055 show avatar placeholder when loading user avatar 2023-07-02 23:34:09 +08:00
MaysWind 9cd22bdc06 add application lock setting tab for desktop 2023-07-02 23:22:59 +08:00
MaysWind 5830d4b91c desktop page support icon/startup image 2023-07-02 23:22:15 +08:00
MaysWind b42f7f566b add unlock page for desktop 2023-07-02 23:22:09 +08:00
MaysWind db8223ca98 fix the language not set to system language 2023-07-02 21:44:56 +08:00
MaysWind 9403afc392 code refactor 2023-07-02 21:44:37 +08:00
MaysWind 2b2d3b9517 code refactor 2023-07-02 20:01:59 +08:00
MaysWind 3eebdfcdb3 modify style 2023-07-02 19:31:47 +08:00
MaysWind f7766bc3d4 update Chinese translation 2023-07-02 19:28:53 +08:00
MaysWind f2614abbdd remove unused code 2023-07-02 19:25:30 +08:00
MaysWind 58824ea879 modify style 2023-07-02 19:09:40 +08:00
MaysWind 9ef7a18847 modify login page style 2023-07-02 19:07:55 +08:00
MaysWind 58c0167696 fix issue 2023-07-02 18:36:11 +08:00
MaysWind 9adfd286f9 code refactor 2023-07-02 00:51:26 +08:00
MaysWind 4e8f530fbb code refactor 2023-07-01 23:38:17 +08:00
MaysWind 3e694b0772 update Chinese translation 2023-07-01 23:27:09 +08:00
MaysWind 2fd5b04d4d code refactor 2023-07-01 23:26:31 +08:00
MaysWind 4688b9a9c9 move "Switch to Mobile Version" to "Use on Mobile Device" dialog 2023-07-01 23:16:53 +08:00
MaysWind 153e0035ba reset state after leaving 2fa setting tab 2023-07-01 23:10:49 +08:00
MaysWind 88b6ecc557 add alert component 2023-07-01 22:51:54 +08:00
MaysWind 9df55874a6 show username and avatar in user basic setting tab 2023-07-01 22:51:42 +08:00
MaysWind 87d4ab827a add reset button in user basic setting tab 2023-07-01 22:12:06 +08:00
MaysWind 318166f23a code refactor 2023-07-01 02:26:55 +08:00
MaysWind cc3f712659 remove unused code 2023-07-01 02:22:30 +08:00
MaysWind ee399d8a08 code refactor 2023-07-01 02:19:04 +08:00
MaysWind 96c233d5c5 code refactor 2023-07-01 02:05:36 +08:00
MaysWind 652b3e1954 support set gravatar as user avatar provider 2023-07-01 00:53:59 +08:00
MaysWind a8b76d5537 modify style 2023-07-01 00:15:17 +08:00
MaysWind e041b70cdd modify style 2023-07-01 00:09:15 +08:00
MaysWind 63bf912b3e upgrade vuetify to 3.3.6 2023-06-30 23:43:13 +08:00
MaysWind 0cbe7b1655 code refactor 2023-06-30 23:41:40 +08:00
MaysWind 7bbec29c5b show whether data is updated after click refresh button 2023-06-28 21:44:01 +08:00
MaysWind 7cec7dbac8 sort result in overview response 2023-06-28 21:41:59 +08:00
MaysWind 09a19b5f42 navigate to desktop page when use tablet device 2023-06-28 21:38:21 +08:00
MaysWind 8fe765c097 modify file name 2023-06-25 00:39:00 +08:00
MaysWind b11e8e07c4 set button disabled when required input is not filled 2023-06-25 00:30:48 +08:00
MaysWind f72763306d code refactor 2023-06-25 00:25:43 +08:00
MaysWind e3d1a476e2 fix wrong watching parameter 2023-06-24 23:59:53 +08:00
MaysWind f0bc86d42f add overview page 2023-06-24 23:55:52 +08:00
MaysWind a62e806175 fix wrong component reference 2023-06-24 21:51:39 +08:00
MaysWind 5bcf5bf93e show mobile url qrcode on desktop page 2023-06-24 21:28:28 +08:00
MaysWind 4f35ba0931 add settings page 2023-06-24 21:27:27 +08:00
MaysWind 2bcdfe778a code refactor 2023-06-24 21:27:19 +08:00
MaysWind c89da1d0f7 code refactor 2023-06-24 21:14:54 +08:00
MaysWind 46a1eda029 add page settings page 2023-06-24 20:01:47 +08:00
MaysWind 1c39819112 add switch to desktop/mobile link 2023-06-24 19:30:28 +08:00
MaysWind 0efe617c03 fix the problem that system default timezone is not browser timezone when custom timezone is set 2023-06-24 18:39:55 +08:00
MaysWind 10df947efe fix npe 2023-06-24 18:30:49 +08:00
MaysWind fb7790ba4a code refactor 2023-06-24 18:27:54 +08:00
MaysWind a9338ed822 code refactor 2023-06-24 14:15:15 +08:00
MaysWind eaab8cdd93 code refactor 2023-06-24 14:07:30 +08:00
MaysWind edfcd0dc6e use autocomplete to replace select 2023-06-24 01:22:35 +08:00
MaysWind 838b56089b remove blank line 2023-06-24 01:22:12 +08:00
MaysWind 178810f908 add user settings page 2023-06-24 01:18:41 +08:00
MaysWind 69f5aca853 fix autocomplete style issue 2023-06-24 00:16:19 +08:00
MaysWind 8e6aece9ae code refactor 2023-06-23 22:23:09 +08:00
MaysWind 59b883ff7f modify style 2023-06-23 21:16:17 +08:00
MaysWind 548d34fbf4 add tooltip 2023-06-23 18:00:31 +08:00
MaysWind bb6345ccfa update vue-datepicker, and make the picker show the calendar view every time opening 2023-06-23 15:48:47 +08:00
MaysWind d59a10f718 fix wrong style 2023-06-23 15:00:38 +08:00
MaysWind aab1c5419a remove unused reference 2023-06-23 14:53:22 +08:00
MaysWind 9b83785b95 modify style 2023-06-23 14:51:07 +08:00
MaysWind 099b710eb1 code refactor 2023-06-23 14:16:37 +08:00
MaysWind a5424afc38 modify style 2023-06-23 13:40:15 +08:00
MaysWind 626325066d code refactor 2023-06-23 13:38:09 +08:00
MaysWind 37195f6008 code refactor 2023-06-23 10:04:33 +08:00
MaysWind fcbc68cefe modify file name 2023-06-23 10:00:05 +08:00
MaysWind 9241953e31 modify style 2023-06-23 01:37:58 +08:00
MaysWind 651a912498 support change theme 2023-06-23 01:24:10 +08:00
MaysWind a05f6fb6b5 modify style 2023-06-23 00:59:14 +08:00
MaysWind d6440d31f2 add exchange rates page 2023-06-23 00:21:00 +08:00
MaysWind 88d5dc2f17 set default timeout setting 2023-06-22 22:54:48 +08:00
MaysWind a17ad85858 add about page 2023-06-22 21:48:19 +08:00
MaysWind 4b49c1f30f add desktop frontend framework 2023-06-22 21:30:18 +08:00
MaysWind a9e36b9a59 modify style 2023-06-22 18:43:39 +08:00
MaysWind 80429bbfb8 code refactor 2023-06-22 16:35:52 +08:00
MaysWind f39e20d7a7 support setting user disabled 2023-06-21 23:57:04 +08:00
MaysWind a03bac5d74 modify style 2023-06-21 23:38:37 +08:00
MaysWind 1aff09598a code refactor 2023-06-21 23:25:56 +08:00
MaysWind 4036a71ee1 update third party dependencies 2023-06-21 22:01:23 +08:00
MaysWind a966be2f46 package framework7 and related dependencies into vendor-mobile.js file 2023-06-21 21:52:31 +08:00
MaysWind a0b3bc1cab fix default text size 2023-06-21 21:50:16 +08:00
MaysWind eb2e2b0a26 show whether data is updated after pull down 2023-06-19 00:12:39 +08:00
MaysWind 55ad7b2e81 code refactor 2023-06-18 21:07:44 +08:00
MaysWind dbcd2897a4 support tomtom map 2023-06-18 21:02:55 +08:00
MaysWind 68a6d1c166 update configuration comment 2023-06-18 20:40:45 +08:00
MaysWind fe82ec6fc2 support OpenStreetMap(Humanitarian), OpenTopoMap, OPNVKarte, CyclOSM 2023-06-18 18:24:11 +08:00
MaysWind 5f2819a961 code refactor 2023-06-18 16:30:19 +08:00
MaysWind 812bfc7cf5 set reference libraries in google map js query 2023-06-18 16:29:30 +08:00
MaysWind d164cafd33 fix npe 2023-06-18 15:53:06 +08:00
MaysWind 3ada4183d9 code refactor 2023-06-18 15:51:57 +08:00
MaysWind fa68621b41 support api proxy for amap 2023-06-18 15:43:27 +08:00
MaysWind 4f2b9d39da code refactor 2023-06-18 09:20:02 +08:00
MaysWind fd01c9269f improve robustness 2023-06-18 01:25:47 +08:00
MaysWind 82d150e53a support amap 2023-06-18 01:24:29 +08:00
MaysWind 251f3fe2da update config comment 2023-06-18 00:26:18 +08:00
MaysWind 4f0e1e6b3d set position and zoom level when init map 2023-06-18 00:06:51 +08:00
MaysWind a5dbf5d4b7 support google map 2023-06-17 23:24:45 +08:00
MaysWind 38baf77c30 support get current language info 2023-06-17 23:07:22 +08:00
MaysWind bfb8b03fc9 add language code 2023-06-17 23:07:11 +08:00
MaysWind 2dd38d9b03 force set default language when specified language not exists, force set locale settings when first set locale settings 2023-06-17 23:04:46 +08:00
MaysWind 307c64bc1e don't write unnecessary info to cookies 2023-06-17 19:44:21 +08:00
MaysWind 782e3a85f9 support baidu map 2023-06-17 19:38:13 +08:00
MaysWind 3bae6e749a support baidu map 2023-06-17 17:47:12 +08:00
MaysWind 530ef6b83e fix default text size is not set to default 2023-06-17 13:09:39 +08:00
MaysWind 5b334eb2d5 show time difference between the transaction timezone and the default timezone on the transaction edit/view page 2023-06-15 01:31:29 +08:00
MaysWind 28dc2e425a fix the problem the end time not equals to the current time in transaction list page when timezone not set to browser timezone 2023-06-15 01:27:15 +08:00
mayswind 83b72e7403 remove duplicated code 2023-06-14 09:12:50 +08:00
MaysWind 01e1f65ffe display preview when drag the range slider 2023-06-14 01:38:52 +08:00
MaysWind 9b15f888e6 improve tree view selection sheet style 2023-06-14 01:36:27 +08:00
MaysWind 171b8afa8e improve text size settings 2023-06-14 01:27:23 +08:00
MaysWind 27f2f9f13a modify transaction edit page loading style 2023-06-14 00:45:49 +08:00
MaysWind 2c3983cead modify transaction list page loading style 2023-06-14 00:45:33 +08:00
MaysWind bebd043d58 add font settings page 2023-06-13 01:33:54 +08:00
MaysWind a1c828fe62 add more supported font size 2023-06-13 01:19:42 +08:00
MaysWind dfb885f38d support setting app font size 2023-06-12 01:39:23 +08:00
MaysWind 702c095544 fix the problem that the last label does not have divider line when filling in a new label 2023-06-11 22:55:12 +08:00
MaysWind b3e886d444 fix npe error 2023-06-11 22:53:32 +08:00
MaysWind 46d85e92cd use pinia to replace vuex, code refactor 2023-06-11 22:08:30 +08:00
MaysWind 0d84f2857f update README 2023-06-10 22:13:54 +08:00
MaysWind ac6c80db90 bump version to 0.4.0 2023-06-10 18:03:03 +08:00
MaysWind e608e85d56 support disable map 2023-06-10 17:14:58 +08:00
MaysWind 58104a9a4d append thousands separator in data management page 2023-06-10 17:04:35 +08:00
MaysWind 8d68dcabb5 list item selection sheet supports large size 2023-06-06 01:01:24 +08:00
MaysWind a5e5389d6c fix some text not changed when user language has been changed 2023-06-06 00:56:39 +08:00
MaysWind 8de862c82f optimize the style when the text is too long 2023-06-06 00:52:35 +08:00
MaysWind 5141303ee1 support turning on dark theme manually 2023-06-05 23:54:01 +08:00
MaysWind 3d95680cbc remove unused code 2023-06-05 09:47:09 +08:00
MaysWind 78663b873c support only match the first locale part when get browser language 2023-06-05 00:56:04 +08:00
MaysWind 0a106026dd user settings supports language and date&time format 2023-06-05 00:54:07 +08:00
MaysWind 999ca6274c remove unused file 2023-06-04 21:08:38 +08:00
MaysWind 36b9177069 fix building issue 2023-06-04 15:43:38 +08:00
MaysWind 8cf7bf859b support map provider and whether use map data proxy settings 2023-06-04 15:06:14 +08:00
MaysWind 2e54b62f60 set cookies in development mode 2023-06-04 14:25:51 +08:00
MaysWind df46069d92 code refactor 2023-06-04 13:15:09 +08:00
MaysWind e31014dde4 code refactor 2023-06-04 13:04:15 +08:00
MaysWind f9a14581e1 fix typo 2023-06-04 01:24:08 +08:00
MaysWind 95ec005894 add icons 2023-06-04 01:22:43 +08:00
MaysWind 73d271b8bc improve ui 2023-06-04 01:22:33 +08:00
MaysWind 0c3b56e44a set the color of input and confirm button to red in password input sheet when clear user data 2023-06-03 20:11:03 +08:00
MaysWind 736f340979 code refactor 2023-06-03 16:49:19 +08:00
MaysWind 49e62d35c3 exchange rate datasource supports Monetary Authority of Singapore 2023-05-29 01:04:16 +08:00
MaysWind 810bce7495 update latest available currencies 2023-05-29 00:27:30 +08:00
MaysWind aff876aa05 only trigger gitea docker-snapshot workflow when push to main branch 2023-05-28 17:59:25 +08:00
MaysWind 9511644ce6 update third party dependencies 2023-05-28 17:59:08 +08:00
MaysWind 21d73e5f69 update golang to 1.20.4, node to 18.16.0, base docker image to alpine 3.17.3 2023-05-28 12:18:40 +08:00
MaysWind d62f3fb936 update github actions 2023-05-21 00:06:11 +08:00
MaysWind 0a011b6075 add gitea actions 2023-05-20 23:49:00 +08:00
MaysWind 4e561dc764 change map marker icon 2023-05-15 23:31:53 +08:00
MaysWind a55256ad82 code refactor 2023-05-15 22:36:21 +08:00
MaysWind ab26ef64a5 code refactor 2023-05-15 22:35:59 +08:00
mayswind 6d1610eee0 fix wrong proxy path 2023-05-15 00:49:02 +08:00
MaysWind 2ba143d6ea support showing geolocation on map 2023-05-15 00:08:03 +08:00
MaysWind bd542ac308 modify geolocation storage type in database 2023-05-14 19:42:57 +08:00
MaysWind e71ffd1a77 support storing geo location in transaction 2023-04-29 13:45:19 +08:00
MaysWind 1ac968f63c improve ui 2023-04-28 17:21:27 +08:00
MaysWind dc4f62a085 modify Simplified Chinese translation 2023-04-28 16:26:52 +08:00
MaysWind ad91d4f1ce add show/hide hidden transaction category/tag menu 2023-04-28 16:24:15 +08:00
MaysWind 8d4c7512ab reduce unit test execute times 2023-04-24 00:54:24 +08:00
MaysWind 12d5837526 modify unit test case fail cause message 2023-04-23 21:49:27 +08:00
MaysWind c82d2abab4 fix problem that cannot switch to other date range sometimes in transaction list page 2023-04-23 01:27:38 +08:00
MaysWind 26cb717a08 build when push to non-main branch 2023-04-23 01:00:27 +08:00
MaysWind 0b1cc0ef5b add lint checking and unit testing in build script 2023-04-23 00:45:56 +08:00
MaysWind 05dc9138b4 fix problem that cannot get transaction statistics when db is set to only_full_group_by 2023-04-23 00:03:26 +08:00
MaysWind a7dcacb26c add log 2023-04-22 23:54:57 +08:00
MaysWind 3ac3f871e4 fix the problem that account list page does not update when modify category of account 2023-04-22 23:12:41 +08:00
MaysWind b180c0bbe6 modify link text 2023-04-22 22:35:36 +08:00
MaysWind e4987f3bde modify vue page name 2023-04-22 22:28:04 +08:00
MaysWind ee2690c2cc fix the problem that cannot change language in transaction preset category page 2023-04-22 21:50:08 +08:00
MaysWind 4cad26f793 change moment api 2023-04-22 21:26:42 +08:00
MaysWind 84f3d5fec5 remove unused code 2023-04-22 21:09:01 +08:00
MaysWind bb3939d570 show none when user does not have visible account or category 2023-04-22 21:03:41 +08:00
MaysWind b303f708f5 improve ui 2023-04-22 20:52:20 +08:00
MaysWind d39550e090 improve ui 2023-04-22 20:47:25 +08:00
MaysWind 22d956f38a improve ui 2023-04-22 20:24:53 +08:00
MaysWind dab7728138 update prompt text 2023-04-22 17:29:30 +08:00
MaysWind 4038b6bc51 improve ui 2023-04-22 17:00:56 +08:00
MaysWind bcaf3a246c show no available category / account in transaction statistics filter page when there are no available category or account 2023-04-22 16:19:10 +08:00
MaysWind 4fb115fcbc remove unused code 2023-04-22 01:37:16 +08:00
MaysWind bfd4b2b6de code refactor 2023-04-22 01:33:38 +08:00
MaysWind c0cd3fc5c2 improve ui 2023-04-22 01:24:49 +08:00
MaysWind f95c4393d2 fix the problem that the day of week in date time / date range picker is wrong 2023-04-22 01:10:22 +08:00
MaysWind e2eb5fabcc improve ui 2023-04-22 00:49:47 +08:00
MaysWind 7275e8ff0d modify infinite distance 2023-04-22 00:31:19 +08:00
MaysWind accfc3df12 improve ui 2023-04-21 23:56:52 +08:00
MaysWind 35392483d9 code refactor 2023-04-21 23:56:44 +08:00
MaysWind 9770851fd4 fix the problem that page jump infinitely 2023-04-21 23:56:02 +08:00
MaysWind e013a6f087 fix the display amount in statistics page when there are no transaction 2023-04-21 23:53:19 +08:00
MaysWind 1ec9ff20b1 remove unused code 2023-04-21 23:41:53 +08:00
MaysWind 9b0dea80c9 don't allow clear the value in datetime picker 2023-04-21 23:14:26 +08:00
MaysWind eea1ea7ed0 always show date range picker in center 2023-04-21 22:36:57 +08:00
MaysWind 85cd46bfc7 fix problem the category separate icon in transaction page does not display 2023-04-21 22:30:07 +08:00
MaysWind a7ca394864 code refactor 2023-04-21 22:20:11 +08:00
MaysWind e178a0795a code refactor 2023-04-21 22:16:35 +08:00
MaysWind e8b0470ceb code refactor 2023-04-21 22:11:10 +08:00
MaysWind c08abfdfdf fix frontend build issue 2023-04-21 08:47:15 +08:00
mayswind b1c765eb51 Upgrade to vue3 (#16)
* upgrade to vue 3.x and framework7 8.x
* change calendar plugin to vue-datepicker
* disable export button when user does not hava any transaction
* implement new pin code input
* append thousands separator in amount in exchange rates page
2023-04-21 01:45:00 +08:00
mayswind 4b0f7d45e8 Merge pull request #15 from vigdail/bugfix/atomic_alignment
Proper fields alignment in `InternalUuidGenerator` struct
2023-04-20 22:49:49 +08:00
vigdail 9e6271b1dc Rearrange fields of InternalUuidGenerator struct to fit atomic alignment requirements 2023-04-20 19:39:38 +06:00
MaysWind a96eb31dc9 redirect to login page when user logout without token 2023-04-16 02:00:59 +08:00
MaysWind 221a7809b6 fix npe error 2023-04-12 00:24:20 +08:00
MaysWind 8c33243c90 code refactor 2023-04-09 01:04:00 +08:00
MaysWind c4b07b98aa update third party dependencies 2023-04-08 14:53:26 +08:00
MaysWind 1fda80a86b bump version to 0.3.0 2023-04-08 14:49:39 +08:00
666 changed files with 100215 additions and 36082 deletions
+16
View File
@@ -0,0 +1,16 @@
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,
}]
}
}
+56
View File
@@ -0,0 +1,56 @@
name: Docker Release
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Set up the environment
run: |
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
cat >> docker/custom-backend-pre-setup.sh <<EOF
#!/bin/sh
${{ vars.CUSTOM_BACKEND_PRE_SETUP }}
EOF
cat >> docker/custom-frontend-pre-setup.sh <<EOF
#!/bin/sh
${{ vars.CUSTOM_FRONTEND_PRE_SETUP }}
EOF
chmod +x docker/custom-backend-pre-setup.sh
chmod +x docker/custom-frontend-pre-setup.sh
- name: Build and push
uses: docker/build-push-action@v4
with:
file: Dockerfile
context: .
platforms: ${{ vars.BUILD_RELEASE_PLATFORMS }}
push: true
build-args: |
RELEASE_BUILD=1
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+54
View File
@@ -0,0 +1,54 @@
name: Docker Snapshot
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
tags: |
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
type=raw,value=latest-snapshot
type=sha,format=short,prefix=SNAPSHOT-
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Set up the environment
run: |
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
cat >> docker/custom-backend-pre-setup.sh <<EOF
#!/bin/sh
${{ vars.CUSTOM_BACKEND_PRE_SETUP }}
EOF
cat >> docker/custom-frontend-pre-setup.sh <<EOF
#!/bin/sh
${{ vars.CUSTOM_FRONTEND_PRE_SETUP }}
EOF
chmod +x docker/custom-backend-pre-setup.sh
chmod +x docker/custom-frontend-pre-setup.sh
- name: Build and push
uses: docker/build-push-action@v4
with:
file: Dockerfile
context: .
platforms: ${{ vars.BUILD_SNAPSHOT_PLATFORMS }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+32 -23
View File
@@ -9,35 +9,44 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Set up docker tag
id: vars
run: echo ::set-output name=RELEASE_TAG::${GITHUB_REF/refs\/tags\/v/}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to DockerHub
uses: docker/login-action@v1
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v2
- name: Build and push
uses: docker/build-push-action@v4
with:
file: Dockerfile
context: .
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
RELEASE_BUILD=1
tags: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${{ steps.vars.outputs.RELEASE_TAG }}
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+31 -23
View File
@@ -9,33 +9,41 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Set up docker tag
id: vars
run: echo ::set-output name=BUILD_DATE::$(date '+%Y%m%d')
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to DockerHub
uses: docker/login-action@v1
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
type=raw,value=latest-snapshot
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v2
- name: Build and push
uses: docker/build-push-action@v4
with:
file: Dockerfile
context: .
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:SNAPSHOT-${{ steps.vars.outputs.BUILD_DATE }}
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:latest-snapshot
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -0,0 +1,28 @@
name: Docker Snapshot
on:
push:
branches-ignore:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build
uses: docker/build-push-action@v2
with:
file: Dockerfile
context: .
platforms: linux/amd64
push: false
+6 -4
View File
@@ -1,5 +1,5 @@
# Build backend binary file
FROM golang:1.20.2-alpine3.17 AS be-builder
FROM golang:1.22.8-alpine3.20 AS be-builder
ARG RELEASE_BUILD
ENV RELEASE_BUILD=$RELEASE_BUILD
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
@@ -9,7 +9,7 @@ RUN apk add git gcc g++ libc-dev
RUN ./build.sh backend
# Build frontend files
FROM node:18.15.0-alpine3.17 AS fe-builder
FROM --platform=$BUILDPLATFORM node:20.18.0-alpine3.20 AS fe-builder
ARG RELEASE_BUILD
ENV RELEASE_BUILD=$RELEASE_BUILD
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
@@ -19,7 +19,7 @@ RUN apk add git
RUN ./build.sh frontend
# Package docker image
FROM alpine:3.17.2
FROM alpine:3.20.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
@@ -27,11 +27,13 @@ COPY docker/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
RUN mkdir -p /ezbookkeeping && chown 1000:1000 /ezbookkeeping \
&& mkdir -p /ezbookkeeping/data && chown 1000:1000 /ezbookkeeping/data \
&& mkdir -p /ezbookkeeping/log && chown 1000:1000 /ezbookkeeping/log
&& mkdir -p /ezbookkeeping/log && chown 1000:1000 /ezbookkeeping/log \
&& mkdir -p /ezbookkeeping/storage && chown 1000:1000 /ezbookkeeping/storage
WORKDIR /ezbookkeeping
COPY --from=be-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/ezbookkeeping /ezbookkeeping/ezbookkeeping
COPY --from=fe-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/dist /ezbookkeeping/public
COPY --chown=1000:1000 conf /ezbookkeeping/conf
COPY --chown=1000:1000 templates /ezbookkeeping/templates
COPY --chown=1000:1000 LICENSE /ezbookkeeping/LICENSE
USER 1000:1000
EXPOSE 8080
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2023 MaysWind (i@mayswind.net)
Copyright (c) 2020-2024 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
+30 -7
View File
@@ -8,17 +8,21 @@
## 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.
Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
## Features
1. Open source & Self-hosted
2. Lightweight & Fast
3. Easy to install
* Docker support
* Multiple database support (sqlite, mysql, etc.)
* Multiple os & architecture support (Windows, macOS, Linux & x86, amd64, ARM)
* Multiple database support (SQLite, MySQL, PostgreSQL, etc.)
* Multiple operation system & hardware support (Windows, macOS, Linux & x86, amd64, ARM)
4. User-friendly interface
* Both desktop and mobile UI
* Close to native app experience (for mobile device)
* Two-level account & two-level category support
* Plentiful preset categories
* Geographic location and map support
* Searching & filtering history records
* Data statistics
* Dark theme
@@ -26,12 +30,15 @@ ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It c
6. Multiple timezone support
7. Multi-language support
8. Two-factor authentication
9. Application lock (WebAuthn support)
10. Data export
9. Application lock (PIN code / WebAuthn)
10. Data import & export
## Screenshots
### Mobile Device
[![ezBookkeeping](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/en.png)](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/en.png)
### Desktop Version
[![ezBookkeeping](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/desktop/en.png)](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/desktop/en.png)
### Mobile Version
[![ezBookkeeping](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)
## Installation
### Ship with docker
@@ -48,19 +55,35 @@ Latest Daily Build:
### Install from binary
Latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://github.com/mayswind/ezbookkeeping/releases)
**Linux / macOS**
$ ./ezbookkeeping server run
ezBookkeeping will listen at port 8080 as default. Then you can visit http://{YOUR_HOST_ADDRESS}:8080/ .
**Windows**
> .\ezbookkeeping.exe server run
ezBookkeeping will listen at port 8080 as default. Then you can visit `http://{YOUR_HOST_ADDRESS}:8080/` .
### Build from source
Make sure you have [Golang](https://golang.org/), [GCC](http://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
**Linux / macOS**
$ ./build.sh package -o ezbookkeeping.tar.gz
All the files will be packaged in `ezbookkeeping.tar.gz`.
**Windows**
> .\build.bat package -o ezbookkeeping.zip
All the files will be packaged in `ezbookkeeping.zip`.
You can also build docker image, make sure you have [docker](https://www.docker.com/) installed, then follow these steps:
**Linux**
$ ./build.sh docker
## Documents
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
+264
View File
@@ -0,0 +1,264 @@
@echo off
set "TYPE="
set "NO_LINT=0"
set "NO_TEST=0"
set "RELEASE=%RELEASE_BUILD%"
set "RELEASE_TYPE=unknown"
set "VERSION="
set "COMMIT_HASH="
set "BUILD_UNIXTIME="
set "BUILD_DATE="
set "PACKAGE_FILENAME="
for /f %%a in ('"prompt $E$S & echo on & for %%b in (1) do rem"') do set "ESC=%%a"
if "%~1"=="" call :show_help & goto :end
goto :pre_parse_args
:echo_red
echo %ESC%[91m%~1%ESC%[0m
goto :eof
:set_unixtime
setlocal enableextensions
for /f %%x in ('wmic path win32_utctime get /format:list ^| findstr "="') do set %%x
set /a z=(14-100%Month%%%100)/12, y=10000%Year%%%10000-z
set /a ut=y*365+y/4-y/100+y/400+(153*(100%Month%%%100+12*z-3)+2)/5+Day-719469
set /a ut=ut*86400+100%Hour%%%100*3600+100%Minute%%%100*60+100%Second%%%100
endlocal & set "%1=%ut%" & goto :eof
:set_date
setlocal enableextensions
for /f %%x in ('wmic path win32_localtime get /format:list ^| findstr "="') do set %%x
if %Month% lss 10 set "Month=0%Month%"
if %Day% lss 10 set "Day=0%Day%"
endlocal & set "%1=%Year%%Month%%Day%" & goto :eof
:check_dependency
if "%~1"=="" goto :eof
where /q %~1 || call :echo_red "Error: "%~1" is required." && goto :end
shift
goto :check_dependency
:show_help
echo ezBookkeeping build script for Windows
echo.
echo Usage:
echo build.cmd type [options]
echo.
echo Types:
echo backend Build backend binary file
echo frontend Build frontend files
echo package Build package archive
echo.
echo Options:
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 /h, --help Show help
goto :eof
:pre_parse_args
if "%~1"=="" goto :post_parse_args
if /i "%~1"=="backend" set "TYPE=%~1" & shift
if /i "%~1"=="frontend" set "TYPE=%~1" & shift
if /i "%~1"=="package" set "TYPE=%~1" & shift
:parse_args
if "%~1"=="" goto :post_parse_args
if /i "%~1"=="/r" set "RELEASE=1" & shift & goto :parse_args
if /i "%~1"=="-r" set "RELEASE=1" & shift & goto :parse_args
if /i "%~1"=="--release" set "RELEASE=1" & shift & goto :parse_args
if /i "%~1"=="/o" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
if /i "%~1"=="-o" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
if /i "%~1"=="--output" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
if /i "%~1"=="--no-lint" set "NO_LINT=1" & shift & goto :parse_args
if /i "%~1"=="--no-test" set "NO_TEST=1" & shift & goto :parse_args
if /i "%~1"=="/h" call :show_help & goto :end
if /i "%~1"=="-h" call :show_help & goto :end
if /i "%~1"=="--help" call :show_help & goto :end
call :echo_red "Invalid argument: %~1" & call :show_help & goto :end
:post_parse_args
if "%RELEASE%"=="" set "RELEASE=0"
if "%RELEASE%"=="0" (
set "RELEASE_TYPE=snapshot"
) else (
set "RELEASE_TYPE=release"
)
:check_type_dependencies
if not defined TYPE call :echo_red "Error: No specified type" & call :show_help & goto :end
call :check_dependency "git"
if "%TYPE%"=="backend" call :check_dependency "go" "gcc"
if "%TYPE%"=="frontend" call :check_dependency "node" "npm"
if "%TYPE%"=="package" call :check_dependency "go" "gcc" "node" "npm" "7z"
if not "%errorlevel%"=="0" goto :end
:set_build_parameters
for /f "tokens=2 delims=:" %%x in ('findstr "\"version\": \"*\"," package.json') do set "VERSION=%%x"
set VERSION=%VERSION: =%
set VERSION=%VERSION:,=%
set VERSION=%VERSION:"=%
for /f %%x in ('git rev-parse --short HEAD') do set "COMMIT_HASH=%%x"
call :set_unixtime BUILD_UNIXTIME
call :set_date BUILD_DATE
:main
if "%TYPE%"=="backend" call :build_backend & goto :end
if "%TYPE%"=="frontend" call :build_frontend & goto :end
if "%TYPE%"=="package" call :build_package & goto :end
goto :end
:build_backend
setlocal enabledelayedexpansion
echo Pulling backend dependencies...
call go get .
if "%NO_LINT%"=="0" (
echo Executing backend lint checking...
call go vet -v .\...
if !errorlevel! neq 0 (
call :echo_red "Error: Failed to pass lint checking"
goto :end
)
)
if "%NO_TEST%"=="0" (
echo Executing backend unit testing...
call go clean -cache
call go test .\... -v
if !errorlevel! neq 0 (
call :echo_red "Error: Failed to pass unit testing"
goto :end
)
)
endlocal
set "CGO_ENABLED=1"
setlocal
set "backend_build_extra_arguments=-X main.Version=%VERSION%"
set "backend_build_extra_arguments=%backend_build_extra_arguments% -X main.CommitHash=%COMMIT_HASH%"
if "%RELEASE%"=="0" (
set "backend_build_extra_arguments=%backend_build_extra_arguments% -X main.BuildUnixTime=%BUILD_UNIXTIME%"
)
echo Building backend binary file (%RELEASE_TYPE%)...
call go build -a -v -trimpath -tags timetzdata -ldflags "-w -s -linkmode external -extldflags '-static' %backend_build_extra_arguments%" -o ezbookkeeping.exe ezbookkeeping.go
endlocal
set "CGO_ENABLED="
goto :eof
:build_frontend
setlocal enabledelayedexpansion
echo Pulling frontend dependencies...
call npm install
if "%NO_LINT%"=="0" (
echo Executing frontend lint checking...
call npm run lint
if !errorlevel! neq 0 (
call :echo_red "Error: Failed to pass lint checking"
goto :end
)
)
endlocal
echo Building frontend files(%RELEASE_TYPE%)...
if "%RELEASE%"=="0" (
set "buildUnixTime=%BUILD_UNIXTIME%"
call npm run build
set "buildUnixTime="
) else (
call npm run build
)
goto :eof
:build_package
setlocal enabledelayedexpansion
set "package_file_name=%VERSION%"
if "%RELEASE%"=="0" (
set "build_date="
set "package_file_name=%package_file_name%-%build_date%"
)
set "package_file_name=ezbookkeeping-%package_file_name%-windows.zip"
if defined PACKAGE_FILENAME set "package_file_name=%PACKAGE_FILENAME%"
echo Building package archive "%package_file_name%" (%RELEASE_TYPE%)...
call :build_backend
if !errorlevel! neq 0 (
goto :end
)
call :build_frontend
if !errorlevel! neq 0 (
goto :end
)
rmdir package /s /q
mkdir package
mkdir package\data
mkdir package\storage
mkdir package\log
xcopy ezbookkeeping.exe package\
xcopy dist package\public /e /i
xcopy conf package\conf /e /i
xcopy templates package\templates /e /i
xcopy LICENSE package\
cd package
if !errorlevel! neq 0 (
call :echo_red "Error: Build Failed"
goto :end
)
call 7z a -r -tzip -mx9 ..\%package_file_name% package *
cd ..
endlocal
goto :eof
:end
set "TYPE="
set "NO_LINT="
set "NO_TEST="
set "RELEASE="
set "RELEASE_TYPE="
set "VERSION="
set "COMMIT_HASH="
set "BUILD_UNIXTIME="
set "BUILD_DATE="
set "PACKAGE_FILENAME="
exit /B
+52 -8
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env sh
TYPE=""
NO_LINT="0"
NO_TEST="0"
RELEASE=${RELEASE_BUILD:-"0"}
RELEASE_TYPE="unknown"
VERSION=""
@@ -40,6 +42,8 @@ Options:
-r, --release Build release (The script will use environment variable "RELEASE_BUILD" to detect whether this is release building by default)
-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
-h, --help Show help
EOF
}
@@ -63,6 +67,12 @@ parse_args() {
DOCKER_TAG="$2"
shift
;;
--no-lint)
NO_LINT="1"
;;
--no-test)
NO_TEST="1"
;;
--help | -h)
show_help
exit 0
@@ -111,6 +121,30 @@ set_build_parameters() {
}
build_backend() {
echo "Pulling backend dependencies..."
go get .
if [ "$NO_LINT" = "0" ]; then
echo "Executing backend lint checking..."
go vet -v ./...
if [ "$?" != "0" ]; then
echo_red "Error: Failed to pass lint checking"
exit 1
fi
fi
if [ "$NO_TEST" = "0" ]; then
echo "Executing backend unit testing..."
go clean -cache
go test ./... -v
if [ "$?" != "0" ]; then
echo_red "Error: Failed to pass unit testing"
exit 1
fi
fi
backend_build_extra_arguments="-X main.Version=$VERSION"
backend_build_extra_arguments="$backend_build_extra_arguments -X main.CommitHash=$COMMIT_HASH"
@@ -125,18 +159,26 @@ build_backend() {
}
build_frontend() {
frontend_build_arguments="";
if [ "$RELEASE" = "0" ]; then
frontend_build_arguments="--buildUnixTime=$BUILD_UNIXTIME"
fi
echo "Pulling frontend dependencies..."
npm install
if [ "$NO_LINT" = "0" ]; then
echo "Executing frontend lint checking..."
npm run lint
if [ "$?" != "0" ]; then
echo_red "Error: Failed to pass lint checking"
exit 1
fi
fi
echo "Building frontend files ($RELEASE_TYPE)..."
export NODE_OPTIONS=--openssl-legacy-provider
npm run build -- "$frontend_build_arguments"
if [ "$RELEASE" = "0" ]; then
buildUnixTime=$BUILD_UNIXTIME npm run build
else
npm run build
fi
}
build_package() {
@@ -160,10 +202,12 @@ build_package() {
rm -rf package
mkdir package
mkdir package/data
mkdir package/storage
mkdir package/log
cp ezbookkeeping package/
cp -R dist package/public
cp -R conf package/conf
cp -R templates package/templates
cp LICENSE package/
cd package || { echo_red "Error: Build Failed"; exit 1; }
+14
View File
@@ -0,0 +1,14 @@
package cmd
import (
"github.com/urfave/cli/v2"
"github.com/mayswind/ezbookkeeping/pkg/core"
)
func bindAction(fn core.CliHandlerFunc) cli.ActionFunc {
return func(cliCtx *cli.Context) error {
c := core.WrapCilContext(cliCtx)
return fn(c)
}
}
+100
View File
@@ -0,0 +1,100 @@
package cmd
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/cron"
"github.com/mayswind/ezbookkeeping/pkg/log"
)
// CronJobs represents the cron command
var CronJobs = &cli.Command{
Name: "cron",
Usage: "ezBookkeeping cron job utilities",
Subcommands: []*cli.Command{
{
Name: "list",
Usage: "List all enabled cron jobs",
Action: bindAction(listAllCronJobs),
},
{
Name: "run",
Usage: "Run specified cron job",
Action: bindAction(runCronJob),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Required: true,
Usage: "Cron job name",
},
},
},
},
}
func listAllCronJobs(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
err = cron.InitializeCronJobSchedulerContainer(c, config, false)
if err != nil {
log.CliErrorf(c, "[cron_jobs.listAllCronJobs] initializes cron job scheduler failed, because %s", err.Error())
return err
}
cronJobs := cron.Container.GetAllJobs()
if len(cronJobs) < 1 {
log.CliErrorf(c, "[cron_jobs.listAllCronJobs] there are no enabled cron jobs")
return err
}
for i := 0; i < len(cronJobs); i++ {
if i > 0 {
fmt.Printf("---\n")
}
cronJob := cronJobs[i]
fmt.Printf("[Name] %s\n", cronJob.Name)
fmt.Printf("[Description] %s\n", cronJob.Description)
fmt.Printf("[Interval] Every %s\n", cronJob.Period.GetInterval())
}
return nil
}
func runCronJob(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
err = cron.InitializeCronJobSchedulerContainer(c, config, false)
if err != nil {
log.CliErrorf(c, "[cron_jobs.runCronJob] initializes cron job scheduler failed, because %s", err.Error())
return err
}
jobName := c.String("name")
err = cron.Container.SyncRunJobNow(jobName)
if err != nil {
log.CliErrorf(c, "[cron_jobs.runCronJob] failed to run cron job \"%s\", because %s", jobName, err.Error())
return err
}
log.CliInfof(c, "[cron_jobs.runCronJob] run cron job \"%s\" successfully", jobName)
return nil
}
+33 -16
View File
@@ -3,6 +3,7 @@ package cmd
import (
"github.com/urfave/cli/v2"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -16,32 +17,32 @@ var Database = &cli.Command{
{
Name: "update",
Usage: "Update database structure",
Action: updateDatabaseStructure,
Action: bindAction(updateDatabaseStructure),
},
},
}
func updateDatabaseStructure(c *cli.Context) error {
func updateDatabaseStructure(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
log.BootInfof("[database.updateDatabaseStructure] starting maintaining")
log.CliInfof(c, "[database.updateDatabaseStructure] starting maintaining")
err = updateAllDatabaseTablesStructure()
err = updateAllDatabaseTablesStructure(c)
if err != nil {
log.BootErrorf("[database.updateDatabaseStructure] update database table structure failed, because %s", err.Error())
log.CliErrorf(c, "[database.updateDatabaseStructure] update database table structure failed, because %s", err.Error())
return err
}
log.BootInfof("[database.updateDatabaseStructure] all tables maintained successfully")
log.CliInfof(c, "[database.updateDatabaseStructure] all tables maintained successfully")
return nil
}
func updateAllDatabaseTablesStructure() error {
func updateAllDatabaseTablesStructure(c *core.CliContext) error {
var err error
err = datastore.Container.UserStore.SyncStructs(new(models.User))
@@ -50,7 +51,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] user table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user table maintained successfully")
err = datastore.Container.UserStore.SyncStructs(new(models.TwoFactor))
@@ -58,7 +59,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] two factor table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] two-factor table maintained successfully")
err = datastore.Container.UserStore.SyncStructs(new(models.TwoFactorRecoveryCode))
@@ -66,7 +67,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] two factor recovery code table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] two-factor recovery code table maintained successfully")
err = datastore.Container.TokenStore.SyncStructs(new(models.TokenRecord))
@@ -74,7 +75,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] token record table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] token record table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.Account))
@@ -82,7 +83,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] account table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] account table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.Transaction))
@@ -90,7 +91,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionCategory))
@@ -98,7 +99,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTag))
@@ -106,7 +107,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction tag table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTagIndex))
@@ -114,7 +115,23 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction tag index table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag index table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTemplate))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction template table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionPictureInfo))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction picture table maintained successfully")
return nil
}
+82 -14
View File
@@ -4,77 +4,142 @@ import (
"encoding/json"
"os"
"github.com/urfave/cli/v2"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/storage"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/uuid"
)
func initializeSystem(c *cli.Context) (*settings.Config, error) {
func initializeSystem(c *core.CliContext) (*settings.Config, error) {
var err error
configFilePath := c.String("conf-path")
isDisableBootLog := c.Bool("no-boot-log")
if configFilePath != "" {
if _, err = os.Stat(configFilePath); err != nil {
log.BootErrorf("[initializer.initializeSystem] cannot load configuration from custom config path %s, because file not exists", configFilePath)
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] cannot load configuration from custom config path %s, because file not exists", configFilePath)
}
return nil, err
}
log.BootInfof("[initializer.initializeSystem] will loading configuration from custom config path %s", configFilePath)
if !isDisableBootLog {
log.BootInfof(c, "[initializer.initializeSystem] will loading configuration from custom config path %s", configFilePath)
}
} else {
configFilePath, err = settings.GetDefaultConfigFilePath()
if err != nil {
log.BootErrorf("[initializer.initializeSystem] cannot get default configuration path, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] cannot get default configuration path, because %s", err.Error())
}
return nil, err
}
log.BootInfof("[initializer.initializeSystem] will load configuration from default config path %s", configFilePath)
if !isDisableBootLog {
log.BootInfof(c, "[initializer.initializeSystem] will load configuration from default config path %s", configFilePath)
}
}
config, err := settings.LoadConfiguration(configFilePath)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] cannot load configuration, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] cannot load configuration, because %s", err.Error())
}
return nil, err
}
if config.SecretKeyNoSet {
log.BootWarnf(c, "[initializer.initializeSystem] \"secret_key\" in config file is not set, please change it to keep your user data safe")
}
settings.SetCurrentConfig(config)
err = datastore.InitializeDataStore(config)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] initializes data store failed, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes data store failed, because %s", err.Error())
}
return nil, err
}
err = log.SetLoggerConfiguration(config)
err = log.SetLoggerConfiguration(config, isDisableBootLog)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] sets logger configuration failed, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] sets logger configuration failed, because %s", err.Error())
}
return nil, err
}
err = storage.InitializeStorageContainer(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes object storage failed, because %s", err.Error())
}
return nil, err
}
err = uuid.InitializeUuidGenerator(config)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] initializes uuid generator failed, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes uuid generator failed, because %s", err.Error())
}
return nil, err
}
err = duplicatechecker.InitializeDuplicateChecker(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes duplicate checker failed, because %s", err.Error())
}
return nil, err
}
err = avatars.InitializeAvatarProvider(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes avatar provider failed, because %s", err.Error())
}
return nil, err
}
err = mail.InitializeMailer(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes mailer failed, because %s", err.Error())
}
return nil, err
}
err = exchangerates.InitializeExchangeRatesDataSource(config)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] initializes exchange rates data source failed, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes exchange rates data source failed, because %s", err.Error())
}
return nil, err
}
cfgJson, _ := json.Marshal(getConfigWithoutSensitiveData(config))
log.BootInfof("[initializer.initializeSystem] has loaded configuration %s", cfgJson)
if !isDisableBootLog {
log.BootInfof(c, "[initializer.initializeSystem] has loaded configuration %s", cfgJson)
}
return config, nil
}
@@ -88,7 +153,10 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
}
clonedConfig.DatabaseConfig.DatabasePassword = "****"
clonedConfig.SMTPConfig.SMTPPasswd = "****"
clonedConfig.MinIOConfig.SecretAccessKey = "****"
clonedConfig.SecretKey = "****"
clonedConfig.AmapApplicationSecret = "****"
return clonedConfig
}
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/urfave/cli/v2"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -16,7 +17,7 @@ var SecurityUtils = &cli.Command{
{
Name: "gen-secret-key",
Usage: "Generate a random secret key",
Action: genSecretKey,
Action: bindAction(genSecretKey),
Flags: []cli.Flag{
&cli.IntFlag{
Name: "length",
@@ -30,7 +31,7 @@ var SecurityUtils = &cli.Command{
},
}
func genSecretKey(c *cli.Context) error {
func genSecretKey(c *core.CliContext) error {
length := c.Int("length")
if length <= 0 {
+378 -44
View File
@@ -2,13 +2,15 @@ package cmd
import (
"fmt"
"github.com/mayswind/ezbookkeeping/pkg/models"
"os"
"github.com/urfave/cli/v2"
clis "github.com/mayswind/ezbookkeeping/pkg/cli"
"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"
)
@@ -20,7 +22,7 @@ var UserData = &cli.Command{
{
Name: "user-add",
Usage: "Add new user",
Action: addNewUser,
Action: bindAction(addNewUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -57,7 +59,7 @@ var UserData = &cli.Command{
{
Name: "user-get",
Usage: "Get specified user info",
Action: getUserInfo,
Action: bindAction(getUserInfo),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -70,7 +72,7 @@ var UserData = &cli.Command{
{
Name: "user-modify-password",
Usage: "Modify user password",
Action: modifyUserPassword,
Action: bindAction(modifyUserPassword),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -86,10 +88,75 @@ var UserData = &cli.Command{
},
},
},
{
Name: "user-enable",
Usage: "Enable specified user",
Action: bindAction(enableUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-disable",
Usage: "Disable specified user",
Action: bindAction(disableUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-resend-verify-email",
Usage: "Resend user verify email",
Action: bindAction(resendUserVerifyEmail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-set-email-verified",
Usage: "Set user email address verified",
Action: bindAction(setUserEmailVerified),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-set-email-unverified",
Usage: "Set user email address unverified",
Action: bindAction(setUserEmailUnverified),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-delete",
Usage: "Delete specified user",
Action: deleteUser,
Action: bindAction(deleteUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -102,7 +169,7 @@ var UserData = &cli.Command{
{
Name: "user-2fa-disable",
Usage: "Disable user 2fa setting",
Action: disableUser2FA,
Action: bindAction(disableUser2FA),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -115,7 +182,7 @@ var UserData = &cli.Command{
{
Name: "user-session-list",
Usage: "List all user sessions",
Action: listUserTokens,
Action: bindAction(listUserTokens),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -128,7 +195,20 @@ var UserData = &cli.Command{
{
Name: "user-session-clear",
Usage: "Clear user all sessions",
Action: clearUserTokens,
Action: bindAction(clearUserTokens),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "send-password-reset-mail",
Usage: "Send password reset mail",
Action: bindAction(sendPasswordResetMail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -141,7 +221,7 @@ var UserData = &cli.Command{
{
Name: "transaction-check",
Usage: "Check whether user all transactions and accounts are correct",
Action: checkUserTransactionAndAccount,
Action: bindAction(checkUserTransactionAndAccount),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -151,10 +231,48 @@ var UserData = &cli.Command{
},
},
},
{
Name: "transaction-tag-index-fix-transaction-time",
Usage: "Fix the transaction tag index data which does not have transaction time",
Action: bindAction(fixTransactionTagIndexNotHaveTransactionTime),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "transaction-import",
Usage: "Import transactions to specified user",
Action: bindAction(importUserTransaction),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
&cli.StringFlag{
Name: "file",
Aliases: []string{"f"},
Required: true,
Usage: "Specific import file path (e.g. transaction.csv)",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Required: true,
Usage: "Import file type (supports \"ezbookkeeping_csv\", \"ezbookkeeping_tsv\")",
},
},
},
{
Name: "transaction-export",
Usage: "Export user all transactions to csv file",
Action: exportUserTransaction,
Usage: "Export user all transactions to file",
Action: bindAction(exportUserTransaction),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -168,12 +286,18 @@ var UserData = &cli.Command{
Required: true,
Usage: "Specific exported file path (e.g. transaction.csv)",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Required: false,
Usage: "Export file type, support csv or tsv, default is csv",
},
},
},
},
}
func addNewUser(c *cli.Context) error {
func addNewUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -189,7 +313,7 @@ func addNewUser(c *cli.Context) error {
user, err := clis.UserData.AddNewUser(c, username, email, nickname, password, defaultCurrency)
if err != nil {
log.BootErrorf("[user_data.addNewUser] error occurs when adding new user")
log.CliErrorf(c, "[user_data.addNewUser] error occurs when adding new user")
return err
}
@@ -198,7 +322,7 @@ func addNewUser(c *cli.Context) error {
return nil
}
func getUserInfo(c *cli.Context) error {
func getUserInfo(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -209,7 +333,7 @@ func getUserInfo(c *cli.Context) error {
user, err := clis.UserData.GetUserByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.getUserInfo] error occurs when getting user data")
log.CliErrorf(c, "[user_data.getUserInfo] error occurs when getting user data")
return err
}
@@ -218,7 +342,7 @@ func getUserInfo(c *cli.Context) error {
return nil
}
func modifyUserPassword(c *cli.Context) error {
func modifyUserPassword(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -230,16 +354,136 @@ func modifyUserPassword(c *cli.Context) error {
err = clis.UserData.ModifyUserPassword(c, username, password)
if err != nil {
log.BootErrorf("[user_data.modifyUserPassword] error occurs when modifying user password")
log.CliErrorf(c, "[user_data.modifyUserPassword] error occurs when modifying user password")
return err
}
log.BootInfof("[user_data.modifyUserPassword] password of user \"%s\" has been changed", username)
log.CliInfof(c, "[user_data.modifyUserPassword] password of user \"%s\" has been changed", username)
return nil
}
func deleteUser(c *cli.Context) error {
func sendPasswordResetMail(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.SendPasswordResetMail(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.sendPasswordResetMail] error occurs when sending password reset email")
return err
}
log.CliInfof(c, "[user_data.sendPasswordResetMail] a password reset email for user \"%s\" has been sent", username)
return nil
}
func enableUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.EnableUser(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.enableUser] error occurs when setting user enabled")
return err
}
log.CliInfof(c, "[user_data.enableUser] user \"%s\" has been set enabled", username)
return nil
}
func disableUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.DisableUser(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.disableUser] error occurs when setting user disabled")
return err
}
log.CliInfof(c, "[user_data.disableUser] user \"%s\" has been set disabled", username)
return nil
}
func resendUserVerifyEmail(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.ResendVerifyEmail(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.resendUserVerifyEmail] error occurs when resending user verify email")
return err
}
log.CliInfof(c, "[user_data.resendUserVerifyEmail] verify email for user \"%s\" has been resent", username)
return nil
}
func setUserEmailVerified(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.SetUserEmailVerified(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.setUserEmailVerified] error occurs when setting user email address verified")
return err
}
log.CliInfof(c, "[user_data.setUserEmailVerified] user \"%s\" email address has been set verified", username)
return nil
}
func setUserEmailUnverified(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.SetUserEmailUnverified(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.setUserEmailUnverified] error occurs when setting user email address unverified")
return err
}
log.CliInfof(c, "[user_data.setUserEmailUnverified] user \"%s\" email address has been set unverified", username)
return nil
}
func deleteUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -250,16 +494,16 @@ func deleteUser(c *cli.Context) error {
err = clis.UserData.DeleteUser(c, username)
if err != nil {
log.BootErrorf("[user_data.deleteUser] error occurs when deleting user")
log.CliErrorf(c, "[user_data.deleteUser] error occurs when deleting user")
return err
}
log.BootInfof("[user_data.deleteUser] user \"%s\" has been deleted", username)
log.CliInfof(c, "[user_data.deleteUser] user \"%s\" has been deleted", username)
return nil
}
func disableUser2FA(c *cli.Context) error {
func disableUser2FA(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -270,16 +514,16 @@ func disableUser2FA(c *cli.Context) error {
err = clis.UserData.DisableUserTwoFactorAuthorization(c, username)
if err != nil {
log.BootErrorf("[user_data.disableUser2FA] error occurs when disabling user two factor authorization")
log.CliErrorf(c, "[user_data.disableUser2FA] error occurs when disabling user two-factor authorization")
return err
}
log.BootInfof("[user_data.disableUser2FA] two factor authorization of user \"%s\" has been disabled", username)
log.CliInfof(c, "[user_data.disableUser2FA] two-factor authorization of user \"%s\" has been disabled", username)
return nil
}
func listUserTokens(c *cli.Context) error {
func listUserTokens(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -290,7 +534,7 @@ func listUserTokens(c *cli.Context) error {
tokens, err := clis.UserData.ListUserTokens(c, username)
if err != nil {
log.BootErrorf("[user_data.listUserTokens] error occurs when getting user tokens")
log.CliErrorf(c, "[user_data.listUserTokens] error occurs when getting user tokens")
return err
}
@@ -305,7 +549,7 @@ func listUserTokens(c *cli.Context) error {
return nil
}
func clearUserTokens(c *cli.Context) error {
func clearUserTokens(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -316,16 +560,16 @@ func clearUserTokens(c *cli.Context) error {
err = clis.UserData.ClearUserTokens(c, username)
if err != nil {
log.BootErrorf("[user_data.clearUserTokens] error occurs when clearing user tokens")
log.CliErrorf(c, "[user_data.clearUserTokens] error occurs when clearing user tokens")
return err
}
log.BootInfof("[user_data.clearUserTokens] all tokens of user \"%s\" has been cleared", username)
log.CliInfof(c, "[user_data.clearUserTokens] all tokens of user \"%s\" has been cleared", username)
return nil
}
func checkUserTransactionAndAccount(c *cli.Context) error {
func checkUserTransactionAndAccount(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -334,21 +578,44 @@ func checkUserTransactionAndAccount(c *cli.Context) error {
username := c.String("username")
log.BootInfof("[user_data.checkUserTransactionAndAccount] starting checking user \"%s\" data", username)
log.CliInfof(c, "[user_data.checkUserTransactionAndAccount] starting checking user \"%s\" data", username)
_, err = clis.UserData.CheckTransactionAndAccount(c, username)
if err != nil {
log.BootErrorf("[user_data.checkUserTransactionAndAccount] error occurs when checking user data")
log.CliErrorf(c, "[user_data.checkUserTransactionAndAccount] error occurs when checking user data")
return err
}
log.BootInfof("[user_data.checkUserTransactionAndAccount] user transactions and accounts data has been checked successfully, there is no problem with user data")
log.CliInfof(c, "[user_data.checkUserTransactionAndAccount] user transactions and accounts data has been checked successfully, there is no problem with user data")
return nil
}
func exportUserTransaction(c *cli.Context) error {
func fixTransactionTagIndexNotHaveTransactionTime(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
log.CliInfof(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] starting fixing user \"%s\" transaction tag index data", username)
_, err = clis.UserData.FixTransactionTagIndexWithTransactionTime(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] error occurs when fixing user data")
return err
}
log.CliInfof(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] user transaction tag index data has been fixed successfully")
return nil
}
func exportUserTransaction(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -357,36 +624,91 @@ func exportUserTransaction(c *cli.Context) error {
username := c.String("username")
filePath := c.String("file")
fileType := c.String("type")
if fileType != "" && fileType != "csv" && fileType != "tsv" {
log.CliErrorf(c, "[user_data.exportUserTransaction] export file type is not supported")
return errs.ErrNotSupported
}
if filePath == "" {
log.BootErrorf("[user_data.exportUserTransaction] export file path is not specified")
log.CliErrorf(c, "[user_data.exportUserTransaction] export file path is unspecified")
return os.ErrNotExist
}
fileExists, err := utils.IsExists(filePath)
if fileExists {
log.BootErrorf("[user_data.exportUserTransaction] specified file path already exists")
log.CliErrorf(c, "[user_data.exportUserTransaction] specified file path already exists")
return os.ErrExist
}
log.BootInfof("[user_data.exportUserTransaction] starting exporting user \"%s\" data", username)
log.CliInfof(c, "[user_data.exportUserTransaction] starting exporting user \"%s\" data", username)
content, err := clis.UserData.ExportTransaction(c, username)
content, err := clis.UserData.ExportTransaction(c, username, fileType)
if err != nil {
log.BootErrorf("[user_data.exportUserTransaction] error occurs when exporting user data")
log.CliErrorf(c, "[user_data.exportUserTransaction] error occurs when exporting user data")
return err
}
err = utils.WriteFile(filePath, content)
if err != nil {
log.BootErrorf("[user_data.exportUserTransaction] failed to write to %s", filePath)
log.CliErrorf(c, "[user_data.exportUserTransaction] failed to write to %s", filePath)
return err
}
log.BootInfof("[user_data.exportUserTransaction] user transactions have been exported to %s", filePath)
log.CliInfof(c, "[user_data.exportUserTransaction] user transactions have been exported to %s", filePath)
return nil
}
func importUserTransaction(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
filePath := c.String("file")
filetype := c.String("type")
if filePath == "" {
log.CliErrorf(c, "[user_data.importUserTransaction] import file path is not specified")
return os.ErrNotExist
}
fileExists, err := utils.IsExists(filePath)
if !fileExists {
log.CliErrorf(c, "[user_data.importUserTransaction] import file does not exist")
return os.ErrExist
}
if filetype != "ezbookkeeping_csv" && filetype != "ezbookkeeping_tsv" {
log.CliErrorf(c, "[user_data.importUserTransaction] unknown file type \"%s\"", filetype)
return errs.ErrImportFileTypeNotSupported
}
data, err := os.ReadFile(filePath)
if err != nil {
log.CliErrorf(c, "[user_data.importUserTransaction] failed to load import file")
return err
}
log.CliInfof(c, "[user_data.importUserTransaction] start importing transactions to user \"%s\"", username)
err = clis.UserData.ImportTransaction(c, username, filetype, data)
if err != nil {
log.CliErrorf(c, "[user_data.importUserTransaction] error occurs when importing user data")
return err
}
log.CliInfof(c, "[user_data.importUserTransaction] transactions have been imported to user \"%s\"", username)
return nil
}
@@ -398,10 +720,21 @@ func printUserInfo(user *models.User) {
fmt.Printf("[Nickname] %s\n", user.Nickname)
fmt.Printf("[Password] %s\n", user.Password)
fmt.Printf("[Salt] %s\n", user.Salt)
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
fmt.Printf("[DefaultAccountId] %d\n", user.DefaultAccountId)
fmt.Printf("[FirstDayOfWeek] %s\n", user.FirstDayOfWeek)
fmt.Printf("[TransactionEditScope] %s\n", user.TransactionEditScope)
fmt.Printf("[TransactionEditScope] %s (%d)\n", user.TransactionEditScope, user.TransactionEditScope)
fmt.Printf("[Language] %s\n", user.Language)
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek)
fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat)
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping)
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("[Deleted] %t\n", user.Deleted)
fmt.Printf("[EmailVerified] %t\n", user.EmailVerified)
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(user.CreatedUnixTime), user.CreatedUnixTime)
@@ -422,5 +755,6 @@ func printUserInfo(user *models.User) {
func printTokenInfo(token *models.TokenRecord) {
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.CreatedUnixTime), token.CreatedUnixTime)
fmt.Printf("[ExpiredAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.ExpiredUnixTime), token.ExpiredUnixTime)
fmt.Printf("[LastSeen] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.LastSeenUnixTime), token.LastSeenUnixTime)
fmt.Printf("[UserAgent] %s\n", token.UserAgent)
}
+130
View File
@@ -0,0 +1,130 @@
package cmd
import (
"encoding/binary"
"fmt"
"net"
"github.com/urfave/cli/v2"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/requestid"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// Utilities represents the utilities command
var Utilities = &cli.Command{
Name: "utility",
Usage: "ezBookkeeping utilities",
Subcommands: []*cli.Command{
{
Name: "parse-default-request-id",
Usage: "Parse a request id which is generated by default request generator and show the details",
Action: bindAction(parseRequestId),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
Required: true,
Usage: "Request ID",
},
},
},
{
Name: "send-test-mail",
Usage: "Send an email to specified e-mail address",
Action: bindAction(sendTestMail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "to",
Required: true,
Usage: "To e-mail address",
},
},
},
},
}
func parseRequestId(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
err = requestid.InitializeRequestIdGenerator(c, config)
defaultGenerator, err := requestid.NewDefaultRequestIdGenerator(c, config)
if err != nil {
return err
}
requestId := c.String("id")
requestIdInfo, err := defaultGenerator.ParseRequestIdInfo(requestId)
if err != nil {
return err
}
newRequestId := defaultGenerator.GenerateRequestId(net.IPv4zero.String(), 0)
newRequestIdInfo, err := defaultGenerator.ParseRequestIdInfo(newRequestId)
printRequestIdInfo(requestId, requestIdInfo, newRequestIdInfo)
return nil
}
func sendTestMail(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
if !config.EnableSMTP || mail.Container.Current == nil {
return errs.ErrSMTPServerNotEnabled
}
toAddress := c.String("to")
err = mail.Container.Current.SendMail(&mail.MailMessage{
To: toAddress,
Subject: "ezBookkeeping test e-mail",
Body: "This is a test e-mail",
})
if err != nil {
return err
}
fmt.Printf("Test e-mail has been sent")
return nil
}
func printRequestIdInfo(requestId string, requestIdInfo *requestid.RequestIdInfo, newRequestIdInfo *requestid.RequestIdInfo) {
fmt.Printf("[RequestId] %s\n", requestId)
fmt.Printf("[ServerUniqId] %d (Current Server %d)\n", requestIdInfo.ServerUniqId, newRequestIdInfo.ServerUniqId)
fmt.Printf("[InstanceUniqId] %d (Current Server %d)\n", requestIdInfo.InstanceUniqId, newRequestIdInfo.InstanceUniqId)
displayTime, err := utils.ParseFromElapsedSeconds(int(requestIdInfo.SecondsElapsedToday))
if err == nil {
fmt.Printf("[SecondsElapsedToday] %d (%s)\n", requestIdInfo.SecondsElapsedToday, displayTime)
} else {
fmt.Printf("[SecondsElapsedToday] %d\n", requestIdInfo.SecondsElapsedToday)
}
fmt.Printf("[RequestSeqId] %d\n", requestIdInfo.RequestSeqId)
fmt.Printf("[IsClientIpv6] %t\n", requestIdInfo.IsClientIpv6)
if requestIdInfo.IsClientIpv6 {
fmt.Printf("[ClientIpv6Hash] %d\n", requestIdInfo.ClientIp)
} else {
ip := make(net.IP, 4)
binary.BigEndian.PutUint32(ip, requestIdInfo.ClientIp)
fmt.Printf("[ClientIpv4] %s\n", ip.String())
}
fmt.Printf("[ClientPort] %d\n", requestIdInfo.ClientPort)
}
+227 -27
View File
@@ -3,7 +3,10 @@ package cmd
import (
"fmt"
"path/filepath"
"time"
"github.com/gin-contrib/cache"
"github.com/gin-contrib/cache/persistence"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
@@ -12,6 +15,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/api"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/cron"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/middlewares"
@@ -29,33 +33,40 @@ var WebServer = &cli.Command{
{
Name: "run",
Usage: "Run ezBookkeeping web server",
Action: startWebServer,
Action: bindAction(startWebServer),
},
},
}
func startWebServer(c *cli.Context) error {
func startWebServer(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
log.BootInfof("[server.startWebServer] static root path is %s", config.StaticRootPath)
log.BootInfof(c, "[webserver.startWebServer] static root path is %s", config.StaticRootPath)
if config.AutoUpdateDatabase {
err = updateAllDatabaseTablesStructure()
err = updateAllDatabaseTablesStructure(c)
if err != nil {
log.BootErrorf("[server.startWebServer] update database table structure failed, because %s", err.Error())
log.BootErrorf(c, "[webserver.startWebServer] update database table structure failed, because %s", err.Error())
return err
}
}
err = requestid.InitializeRequestIdGenerator(config)
err = requestid.InitializeRequestIdGenerator(c, config)
if err != nil {
log.BootErrorf("[server.startWebServer] initializes requestid generator failed, because %s", err.Error())
log.BootErrorf(c, "[webserver.startWebServer] initializes requestid generator failed, because %s", err.Error())
return err
}
err = cron.InitializeCronJobSchedulerContainer(c, config, true)
if err != nil {
log.BootErrorf(c, "[webserver.startWebServer] initializes cron job scheduler failed, because %s", err.Error())
return err
}
@@ -65,12 +76,14 @@ func startWebServer(c *cli.Context) error {
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
}
log.BootInfof("[server.startWebServer] %s%s", serverInfo, uuidServerInfo)
log.BootInfof(c, "[webserver.startWebServer] %s%s", serverInfo, uuidServerInfo)
if config.Mode == settings.MODE_PRODUCTION {
gin.SetMode(gin.ReleaseMode)
}
workboxFileNames := utils.ListFileNamesWithPrefixAndSuffix(config.StaticRootPath, "workbox-", ".js")
router := gin.New()
router.Use(bindMiddleware(middlewares.Recovery))
@@ -84,6 +97,7 @@ func startWebServer(c *cli.Context) error {
_ = v.RegisterValidation("validEmail", validators.ValidEmail)
_ = v.RegisterValidation("validCurrency", validators.ValidCurrency)
_ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor)
_ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter)
}
router.NoRoute(bindApi(api.Default.ApiNotFound))
@@ -110,13 +124,16 @@ func startWebServer(c *cli.Context) error {
router.Static("/mobile/css", filepath.Join(config.StaticRootPath, "css"))
router.Static("/mobile/img", filepath.Join(config.StaticRootPath, "img"))
router.Static("/mobile/fonts", filepath.Join(config.StaticRootPath, "fonts"))
router.Static("/mobile/sw", filepath.Join(config.StaticRootPath, "sw"))
router.StaticFile("/mobile/favicon.ico", filepath.Join(config.StaticRootPath, "favicon.ico"))
router.StaticFile("/mobile/favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
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"))
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)))
{
@@ -130,30 +147,114 @@ func startWebServer(c *cli.Context) error {
router.StaticFile("/desktop/favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
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"))
for i := 0; i < len(workboxFileNames); i++ {
router.StaticFile("/desktop/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
}
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
avatarRoute := router.Group("/avatar")
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
{
avatarRoute.GET("/:fileName", bindImage(api.Users.UserGetAvatarHandler))
}
}
if config.EnableTransactionPictures {
pictureRoute := router.Group("/pictures")
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
{
pictureRoute.GET("/:fileName", bindImage(api.TransactionPictures.TransactionPictureGetHandler))
}
}
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))
{
if config.EnableMapDataFetchProxy {
if 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 {
proxyRoute.GET("/map/tile/:zoomLevel/:coordinateX/:fileName", bindProxy(api.MapImages.MapTileImageProxyHandler))
}
if config.MapProvider == settings.TianDiTuProvider ||
(config.MapProvider == settings.CustomProvider && config.CustomMapTileServerAnnotationLayerUrl != "") {
proxyRoute.GET("/map/annotation/:zoomLevel/:coordinateX/:fileName", bindProxy(api.MapImages.MapAnnotationImageProxyHandler))
}
}
}
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
amapApiProxyRoute := router.Group("/_AMapService")
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie))
{
amapApiProxyRoute.GET("/*action", bindProxy(api.AmapApis.AmapApiProxyHandler))
}
}
qrCodeRoute := router.Group("/qrcode")
qrCodeRoute.Use(bindMiddleware(middlewares.RequestId(config)))
{
qrCodeCacheStore := persistence.NewInMemoryStore(time.Minute)
qrCodeRoute.GET("/mobile_url.png", bindCachedImage(api.QrCodes.MobileUrlQrCodeHandler, qrCodeCacheStore))
}
apiRoute := router.Group("/api")
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
apiRoute.Use(bindMiddleware(middlewares.RequestLog))
{
apiRoute.POST("/authorize.json", bindApi(api.Authorizations.AuthorizeHandler))
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
if config.EnableTwoFactor {
twoFactorRoute := apiRoute.Group("/2fa")
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization))
{
twoFactorRoute.POST("/authorize.json", bindApi(api.Authorizations.TwoFactorAuthorizeHandler))
twoFactorRoute.POST("/recovery.json", bindApi(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler))
twoFactorRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeHandler, config))
twoFactorRoute.POST("/recovery.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler, config))
}
}
if config.EnableUserRegister {
apiRoute.POST("/register.json", bindApi(api.Users.UserRegisterHandler))
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
}
apiRoute.GET("/logout.json", bindApi(api.Tokens.TokenRevokeCurrentHandler))
if config.EnableUserVerifyEmail {
apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler))
emailVerifyRoute := apiRoute.Group("/verify_email")
emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization))
{
emailVerifyRoute.POST("/by_token.json", bindApi(api.Users.UserEmailVerifyHandler))
}
}
if config.EnableUserForgetPassword {
apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler))
resetPasswordRoute := apiRoute.Group("/forget_password/reset")
resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization))
{
resetPasswordRoute.POST("/by_token.json", bindApi(api.ForgetPasswords.UserResetPasswordHandler))
}
}
apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config))
apiV1Route := apiRoute.Group("/v1")
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization))
@@ -162,17 +263,26 @@ func startWebServer(c *cli.Context) error {
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
apiV1Route.POST("/tokens/revoke.json", bindApi(api.Tokens.TokenRevokeHandler))
apiV1Route.POST("/tokens/revoke_all.json", bindApi(api.Tokens.TokenRevokeAllHandler))
apiV1Route.POST("/tokens/refresh.json", bindApi(api.Tokens.TokenRefreshHandler))
apiV1Route.POST("/tokens/refresh.json", bindApiWithTokenUpdate(api.Tokens.TokenRefreshHandler, config))
// Users
apiV1Route.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler))
apiV1Route.POST("/users/profile/update.json", bindApi(api.Users.UserUpdateProfileHandler))
apiV1Route.POST("/users/profile/update.json", bindApiWithTokenUpdate(api.Users.UserUpdateProfileHandler, config))
// Two Factor Authorization
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
apiV1Route.POST("/users/avatar/update.json", bindApi(api.Users.UserUpdateAvatarHandler))
apiV1Route.POST("/users/avatar/remove.json", bindApi(api.Users.UserRemoveAvatarHandler))
}
if config.EnableUserVerifyEmail {
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
}
// Two-Factor Authorization
if config.EnableTwoFactor {
apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler))
apiV1Route.POST("/users/2fa/enable/request.json", bindApi(api.TwoFactorAuthorizations.TwoFactorEnableRequestHandler))
apiV1Route.POST("/users/2fa/enable/confirm.json", bindApi(api.TwoFactorAuthorizations.TwoFactorEnableConfirmHandler))
apiV1Route.POST("/users/2fa/enable/confirm.json", bindApiWithTokenUpdate(api.TwoFactorAuthorizations.TwoFactorEnableConfirmHandler, config))
apiV1Route.POST("/users/2fa/disable.json", bindApi(api.TwoFactorAuthorizations.TwoFactorDisableHandler))
apiV1Route.POST("/users/2fa/recovery/regenerate.json", bindApi(api.TwoFactorAuthorizations.TwoFactorRecoveryCodeRegenerateHandler))
}
@@ -182,7 +292,8 @@ func startWebServer(c *cli.Context) error {
apiV1Route.POST("/data/clear.json", bindApi(api.DataManagements.ClearDataHandler))
if config.EnableDataExport {
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataHandler))
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
apiV1Route.GET("/data/export.tsv", bindTsv(api.DataManagements.ExportDataToEzbookkeepingTSVHandler))
}
// Accounts
@@ -199,13 +310,24 @@ func startWebServer(c *cli.Context) error {
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler))
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
apiV1Route.GET("/transactions/amounts/by_month.json", bindApi(api.Transactions.TransactionMonthAmountsHandler))
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
if config.EnableDataImport {
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
}
// Transaction Pictures
if config.EnableTransactionPictures {
apiV1Route.POST("/transaction/pictures/upload.json", bindApi(api.TransactionPictures.TransactionPictureUploadHandler))
apiV1Route.POST("/transaction/pictures/remove_unused.json", bindApi(api.TransactionPictures.TransactionPictureRemoveUnusedHandler))
}
// Transaction Categories
apiV1Route.GET("/transaction/categories/list.json", bindApi(api.TransactionCategories.CategoryListHandler))
apiV1Route.GET("/transaction/categories/get.json", bindApi(api.TransactionCategories.CategoryGetHandler))
@@ -225,6 +347,15 @@ func startWebServer(c *cli.Context) error {
apiV1Route.POST("/transaction/tags/move.json", bindApi(api.TransactionTags.TagMoveHandler))
apiV1Route.POST("/transaction/tags/delete.json", bindApi(api.TransactionTags.TagDeleteHandler))
// Transaction Templates
apiV1Route.GET("/transaction/templates/list.json", bindApi(api.TransactionTemplates.TemplateListHandler))
apiV1Route.GET("/transaction/templates/get.json", bindApi(api.TransactionTemplates.TemplateGetHandler))
apiV1Route.POST("/transaction/templates/add.json", bindApi(api.TransactionTemplates.TemplateCreateHandler))
apiV1Route.POST("/transaction/templates/modify.json", bindApi(api.TransactionTemplates.TemplateModifyHandler))
apiV1Route.POST("/transaction/templates/hide.json", bindApi(api.TransactionTemplates.TemplateHideHandler))
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
// Exchange Rates
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
}
@@ -233,20 +364,20 @@ func startWebServer(c *cli.Context) error {
listenAddr := fmt.Sprintf("%s:%d", config.HttpAddr, config.HttpPort)
if config.Protocol == settings.SCHEME_SOCKET {
log.BootInfof("[server.startWebServer] will run at socks:%s", config.UnixSocketPath)
log.BootInfof(c, "[webserver.startWebServer] will run at socks:%s", config.UnixSocketPath)
err = router.RunUnix(config.UnixSocketPath)
} else if config.Protocol == settings.SCHEME_HTTP {
log.BootInfof("[server.startWebServer] will run at http://%s", listenAddr)
log.BootInfof(c, "[webserver.startWebServer] will run at http://%s", listenAddr)
err = router.Run(listenAddr)
} else if config.Protocol == settings.SCHEME_HTTPS {
log.BootInfof("[server.startWebServer] will run at https://%s", listenAddr)
log.BootInfof(c, "[webserver.startWebServer] will run at https://%s", listenAddr)
err = router.RunTLS(listenAddr, config.CertFile, config.CertKeyFile)
} else {
err = errs.ErrInvalidProtocol
}
if err != nil {
log.BootErrorf("[server.startWebServer] cannot start, because %s", err)
log.BootErrorf(c, "[webserver.startWebServer] cannot start, because %s", err)
return err
}
@@ -255,13 +386,13 @@ func startWebServer(c *cli.Context) error {
func bindMiddleware(fn core.MiddlewareHandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
fn(core.WrapContext(c))
fn(core.WrapWebContext(c))
}
}
func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
result, err := fn(c)
if err != nil {
@@ -272,9 +403,26 @@ func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
}
}
func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, err := fn(c)
if err == nil && config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
middlewares.AmapApiProxyAuthCookie(c, config)
}
if err != nil {
utils.PrintJsonErrorResult(c, err)
} else {
utils.PrintJsonSuccessResult(c, result)
}
}
}
func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
result, fileName, err := fn(c)
if err != nil {
@@ -284,3 +432,55 @@ func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
}
}
}
func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, fileName, err := fn(c)
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
utils.PrintDataSuccessResult(c, "text/tab-separated-values", fileName, result)
}
}
}
func bindImage(fn core.ImageHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, contentType, err := fn(c)
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
utils.PrintDataSuccessResult(c, contentType, "", result)
}
}
}
func bindCachedImage(fn core.ImageHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, contentType, err := fn(c)
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
utils.PrintDataSuccessResult(c, contentType, "", result)
}
})
}
func bindProxy(fn core.ProxyHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
proxy, err := fn(c)
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
proxy.ServeHTTP(c.Writer, c.Request)
}
}
}
+227 -10
View File
@@ -25,7 +25,7 @@ root_url = %(protocol)s://%(domain)s:%(http_port)s/
cert_file =
cert_key_file =
# Unix socket path, for "socket" only
# Unix socket path, for "socket" protocol only
unix_socket =
# Static file root path (relative or absolute path)
@@ -47,10 +47,10 @@ name = ezbookkeeping
user = root
passwd =
# For "postgres" only, Either "disable", "require" or "verify-full"
# For "postgres" database only, Either "disable", "require" or "verify-full"
ssl_mode = disable
# For "sqlite3" only, db file path (relative or absolute path)
# For "sqlite3" database only, database file path (relative or absolute path)
db_path = data/ezbookkeeping.db
# Max idle connection number (0 - 65535, 0 means no idle connections are retained), default is 2
@@ -68,6 +68,19 @@ log_query = false
# Set to true to automatically update database structure when starting web server
auto_update_database = true
[mail]
# Set to true to enable sending mail by SMTP server
enable_smtp = false
# SMTP Server connection configuration
smtp_host = 127.0.0.1:25
smtp_user =
smtp_passwd =
smtp_skip_tls_verify = false
# Mail from address. This can be just an email address, or the "Name" <user@domain.com> format.
from_address =
[log]
# Either "console", "file", default is "console"
# Use space to separate multiple modes, e.g. "console file"
@@ -76,29 +89,97 @@ mode = console file
# Either "debug", "info", "warn", "error", default is "info"
level = info
# For "file" only, log file path (relative or absolute path)
# For "file" mode only, log file path (relative or absolute path)
log_path = log/ezbookkeeping.log
# For "file" only, request log file path (relative or absolute path). Leave blank if you want to write request log in default log file
request_log_path =
# For "file" only, query log file path (relative or absolute path). Leave blank if you want to write query log in default log file
query_log_path =
# For "file" only, whether rotate the log files
log_file_rotate = false
# For "file" only, maximum size (1 - 4294967295 bytes) of the log file before it gets rotated
log_file_max_size = 104857600
# For "file" only, maximum number of days to retain old log files. Set to 0 to retain all logs
log_file_max_days = 7
[storage]
# Object storage type, supports "local_filesystem" and "minio" currently
type = local_filesystem
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
local_filesystem_path = storage/
# For "minio" storage only, the minio connection configuration
minio_endpoint = 127.0.0.1:9000
minio_location =
minio_access_key_id =
minio_secret_access_key =
# For "minio" storage only, whether enable ssl for minio connection
minio_use_ssl = false
# For "minio" storage only, set to true to skip tls verification when connect minio
minio_skip_tls_verify = false
# For "minio" storage only, the minio bucket
minio_bucket = ezbookkeeping
# For "minio" storage only, the root path to store files in minio
minio_root_path = /
[uuid]
# Uuid generator type, supports "internal" currently
generator_type = internal
# For "internal" only, each server must have unique id (0 - 255)
# For "internal" uuid generator only, each server must have unique id (0 - 255)
server_id = 0
[duplicate_checker]
# Duplicate checker type, supports "in_memory" currently
checker_type = in_memory
# For "in_memory" duplicate checker only, cleanup expired data interval seconds (1 - 4294967295), default is 60 (1 minutes)
cleanup_interval = 60
# The minimum interval seconds (0 - 4294967295) between duplicate submissions on the same page (exiting and re-entering the edit page / edit dialog is considered as a new session)
# Set to 0 to disable duplicate checker for new data submissions, default is 300 (5 minutes)
duplicate_submissions_interval = 300
[cron]
# Set to true to clean up expired tokens periodically
enable_remove_expired_tokens = true
# Set to true to create scheduled transactions based on the user's templates
enable_create_scheduled_transaction = true
[security]
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
secret_key =
# Set to true to enable two factor authorization
# Set to true to enable two-factor authorization
enable_two_factor = true
# Token expired seconds (0 - 4294967295), default is 2592000 (30 days)
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
token_expired_time = 2592000
# Temporary token expired seconds (0 - 4294967295), default is 300 (5 minutes)
# Token minimum refresh interval (0 - 4294967295), the value should be less than token expired time
# Set to 0 to refresh the token every time when refreshing the front end, default is 86400 (1 day)
token_min_refresh_interval = 86400
# Temporary token expired seconds (60 - 4294967295), default is 300 (5 minutes)
temporary_token_expired_time = 300
# Email verify token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
email_verify_token_expired_time = 3600
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
password_reset_token_expired_time = 3600
# Add X-Request-Id header to response to track user request or error, default is true
request_id_header = true
@@ -106,13 +187,149 @@ request_id_header = true
# Set to true to allow users to register account by themselves
enable_register = true
# Set to true to allow users to verify email address
enable_email_verify = false
# Set to true to require email must be verified when login
enable_force_email_verify = false
# Set to true to allow users to reset password
enable_forget_password = true
# Set to true to require email must be verified when use forget password
forget_password_require_email_verify = false
# Set to true to allow users to upload transaction pictures
enable_transaction_picture = true
# Maximum allowed transaction picture file size (1 - 4294967295 bytes)
max_transaction_picture_size = 10485760
# Set to true to allow users to create scheduled transaction
enable_scheduled_transaction = true
# User avatar provider, supports the following types:
# "internal": Use the internal object storage to store user avatar (refer to "storage" settings), supports updating avatar by user self
# "gravatar": https://gravatar.com
# Leave blank if you want to disable user avatar
avatar_provider = internal
# For "internal" avatar provider only, maximum allowed user avatar file size (1 - 4294967295 bytes)
max_user_avatar_size = 1048576
[data]
# Set to true to allow users to export their data
enable_export = true
# Set to true to allow users to import their data
enable_import = true
# Maximum allowed import file size (1 - 4294967295 bytes)
max_import_file_size = 10485760
[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
after_register_notification_content =
# Set to true to display custom notification in home page every time users login
enable_notification_after_login = false
# The notification content displayed each time users log in, it supports multi-language configuration
after_login_notification_content =
# Set to true to display custom notification in home page every time users open the app
enable_notification_after_open = false
# The notification content displayed each time users open the app, it supports multi-language configuration
after_open_notification_content =
[map]
# Map provider, supports the following types:
# "openstreetmap": https://www.openstreetmap.org
# "openstreetmap_humanitarian": http://map.hotosm.org
# "opentopomap": https://opentopomap.org
# "opnvkarte": https://publictransportmap.org
# "cyclosm": https://www.cyclosm.org
# "cartodb": https://carto.com/basemaps
# "tomtom": https://www.tomtom.com
# "tianditu": https://www.tianditu.gov.cn
# "googlemap": https://map.google.com
# "baidumap": https://map.baidu.com
# "amap": https://amap.com
# "custom": custom map tile server url
# Leave blank if you want to disable map
map_provider = openstreetmap
# Set to true to use the ezbookkeeping server to forward map data requests, for "openstreetmap", "openstreetmap_humanitarian", "opentopomap", "opnvkarte", "cyclosm", "cartodb", "tomtom", "tianditu" or "custom"
map_data_fetch_proxy = false
# Proxy for ezbookkeeping server requesting original map data when map_data_fetch_proxy is set to true, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
proxy = system
# For "tomtom" map provider only, TomTom map API key, please visit https://developer.tomtom.com/how-to-get-tomtom-api-key for more information
tomtom_map_api_key =
# For "tianditu" map provider only, TianDiTu map application key, please visit https://console.tianditu.gov.cn/api/register for more information
tianditu_map_app_key =
# For "googlemap" map provider only, Google map JavaScript API key, please visit https://developers.google.com/maps/get-started for more information
google_map_api_key =
# For "baidumap" map provider only, Baidu map JavaScript API application key, please visit https://lbsyun.baidu.com/index.php?title=jspopular3.0/guide/getkey for more information
baidu_map_ak =
# For "amap" map provider only, Amap JavaScript API application key, please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
amap_application_key =
# For "amap" map provider only, Amap JavaScript API security verification method, supports the following methods:
# "internal_proxy": use the internal proxy to request amap api with amap application secret (default)
# "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
# 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 =
# For "amap" map provider only, Amap JavaScript API external proxy url, this setting must be provided when "amap_security_verification_method" is set to "external_proxy"
amap_api_external_proxy_url =
# For "custom" map provider only, the tile layer url of custom map tile server, supports {x}, {y} (coordinates) and {z} (zoom level) placeholders, like "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
custom_map_tile_server_url =
# For "custom" map provider only, the optional annotation layer url of custom map tile server, supports {x}, {y} (coordinates) and {z} (zoom level) placeholders
custom_map_tile_server_annotation_url =
# For "custom" map provider only, the min zoom level (0 - 255) for custom map tile server, default is 1
custom_map_tile_server_min_zoom_level = 1
# For "custom" map provider only, the max zoom level (0 - 255) for custom map tile server, default is 18
custom_map_tile_server_max_zoom_level = 18
# For "custom" map provider only, the default zoom level (0 - 255) for custom map tile server, default is 14
custom_map_tile_server_default_zoom_level = 14
[exchange_rates]
# Exchange rates data source, supports "euro_central_bank", "bank_of_canada", "reserve_bank_of_australia", "czech_national_bank", "national_bank_of_poland" currently
# 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
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
# "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/
# "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx
data_source = euro_central_bank
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds), default is 10000 (10 seconds)
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds)
# Set to 0 to disable timeout for requesting exchange rates data, default is 10000 (10 seconds)
request_timeout = 10000
# Proxy for ezbookkeeping server requesting exchange rates data, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
proxy = system
# Set to true to skip tls verification when request exchange rates data
skip_tls_verify = false
+6
View File
@@ -36,13 +36,19 @@ func main() {
cmd.WebServer,
cmd.Database,
cmd.UserData,
cmd.CronJobs,
cmd.SecurityUtils,
cmd.Utilities,
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "conf-path",
Usage: "Custom config `FILE` path",
},
&cli.BoolFlag{
Name: "no-boot-log",
Usage: "Disable boot log",
},
},
}
+63 -30
View File
@@ -1,53 +1,86 @@
module github.com/mayswind/ezbookkeeping
go 1.20
go 1.22
require (
github.com/gin-contrib/gzip v0.0.6
github.com/gin-gonic/gin v1.9.0
github.com/go-playground/validator/v10 v10.12.0
github.com/go-sql-driver/mysql v1.7.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/lib/pq v1.10.7
github.com/mattn/go-sqlite3 v1.14.16
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-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-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/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.4.0
github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.2
github.com/urfave/cli/v2 v2.25.0
golang.org/x/crypto v0.7.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/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.26.0
golang.org/x/text v0.17.0
gopkg.in/ini.v1 v1.67.0
xorm.io/xorm v1.3.2
gopkg.in/mail.v2 v2.3.1
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.9
)
require (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/bytedance/sonic v1.8.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
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/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/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/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.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/redigo v1.8.9 // 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/cpuid/v2 v2.0.9 // indirect
github.com/leodido/go-urn v1.2.2 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/memcachier/mc/v3 v3.0.3 // indirect
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.0.6 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // 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/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.9 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/text v0.8.0 // indirect
google.golang.org/protobuf v1.28.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/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
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
xorm.io/builder v0.3.12 // indirect
)
+132 -671
View File
@@ -1,741 +1,202 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
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.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
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/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=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a h1:c5k29baTzznteWs+9dxrtqpNxgtQ3V5NbU8d6laLK9Q=
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a/go.mod h1:xbpgo9r3xURoPa/l3sLKLGcnWlkz9UkfFsQ7lW0S6h8=
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
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/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
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/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
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-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.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
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.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.12.0 h1:E4gtWgxWxp8YSxExrQFv5BpCahla0PVF2oTTEYaWQGI=
github.com/go-playground/validator/v10 v10.12.0/go.mod h1:hCAPuzYvKdP33pxWa+2+6AIKXEKqjIUyqsNCtbsSJrA=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
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-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/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/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/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/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
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=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.8.1/go.mod h1:JV6m6b6jhjdmzchES0drzCcYcAHS1OPD5xu3OZ/lE2g=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
github.com/jackc/pgtype v1.7.0/go.mod h1:ZnHF+rMePVqDKaOfJVI4Q8IVvAQMryDlDkZnKOI75BE=
github.com/jackc/pgtype v1.8.0/go.mod h1:PqDKcEBtllAtk/2p6z6SHdXW5UB+MhE75tUol2OKexE=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
github.com/jackc/pgx/v4 v4.11.0/go.mod h1:i62xJgdrtVDsnL3U8ekyrQXEwGNTRoG7/8r+CIdYfcc=
github.com/jackc/pgx/v4 v4.12.0/go.mod h1:fE547h6VulLPA3kySjfnSG/e2D861g/50JlVUa/ub60=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
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/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
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/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/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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/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/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=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/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/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/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/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU=
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.25.0 h1:ykdZKuQey2zq0yin/l7JOm9Mh+pg72ngYMeB0ABn6q8=
github.com/urfave/cli/v2 v2.25.0/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
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/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/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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/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.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
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/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
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=
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=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.33.9/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.33.11/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.34.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.4/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.5/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.7/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.8/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.10/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.15/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.16/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.17/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.18 h1:rMZhRcWrba0y3nVmdiQ7kxAgOOSq2m2f2VzjHLgEs6U=
modernc.org/cc/v3 v3.35.18/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/ccgo/v3 v3.9.5/go.mod h1:umuo2EP2oDSBnD3ckjaVUXMrmeAw8C8OSICVa0iFf60=
modernc.org/ccgo/v3 v3.10.0/go.mod h1:c0yBmkRFi7uW4J7fwx/JiijwOjeAeR2NoSaRVFPmjMw=
modernc.org/ccgo/v3 v3.11.0/go.mod h1:dGNposbDp9TOZ/1KBxghxtUp/bzErD0/0QW4hhSaBMI=
modernc.org/ccgo/v3 v3.11.1/go.mod h1:lWHxfsn13L3f7hgGsGlU28D9eUOf6y3ZYHKoPaKU0ag=
modernc.org/ccgo/v3 v3.11.3/go.mod h1:0oHunRBMBiXOKdaglfMlRPBALQqsfrCKXgw9okQ3GEw=
modernc.org/ccgo/v3 v3.12.4/go.mod h1:Bk+m6m2tsooJchP/Yk5ji56cClmN6R1cqc9o/YtbgBQ=
modernc.org/ccgo/v3 v3.12.6/go.mod h1:0Ji3ruvpFPpz+yu+1m0wk68pdr/LENABhTrDkMDWH6c=
modernc.org/ccgo/v3 v3.12.8/go.mod h1:Hq9keM4ZfjCDuDXxaHptpv9N24JhgBZmUG5q60iLgUo=
modernc.org/ccgo/v3 v3.12.11/go.mod h1:0jVcmyDwDKDGWbcrzQ+xwJjbhZruHtouiBEvDfoIsdg=
modernc.org/ccgo/v3 v3.12.14/go.mod h1:GhTu1k0YCpJSuWwtRAEHAol5W7g1/RRfS4/9hc9vF5I=
modernc.org/ccgo/v3 v3.12.18/go.mod h1:jvg/xVdWWmZACSgOiAhpWpwHWylbJaSzayCqNOJKIhs=
modernc.org/ccgo/v3 v3.12.20/go.mod h1:aKEdssiu7gVgSy/jjMastnv/q6wWGRbszbheXgWRHc8=
modernc.org/ccgo/v3 v3.12.21/go.mod h1:ydgg2tEprnyMn159ZO/N4pLBqpL7NOkJ88GT5zNU2dE=
modernc.org/ccgo/v3 v3.12.22/go.mod h1:nyDVFMmMWhMsgQw+5JH6B6o4MnZ+UQNw1pp52XYFPRk=
modernc.org/ccgo/v3 v3.12.25/go.mod h1:UaLyWI26TwyIT4+ZFNjkyTbsPsY3plAEB6E7L/vZV3w=
modernc.org/ccgo/v3 v3.12.29/go.mod h1:FXVjG7YLf9FetsS2OOYcwNhcdOLGt8S9bQ48+OP75cE=
modernc.org/ccgo/v3 v3.12.36/go.mod h1:uP3/Fiezp/Ga8onfvMLpREq+KUjUmYMxXPO8tETHtA8=
modernc.org/ccgo/v3 v3.12.38/go.mod h1:93O0G7baRST1vNj4wnZ49b1kLxt0xCW5Hsa2qRaZPqc=
modernc.org/ccgo/v3 v3.12.43/go.mod h1:k+DqGXd3o7W+inNujK15S5ZYuPoWYLpF5PYougCmthU=
modernc.org/ccgo/v3 v3.12.46/go.mod h1:UZe6EvMSqOxaJ4sznY7b23/k13R8XNlyWsO5bAmSgOE=
modernc.org/ccgo/v3 v3.12.47/go.mod h1:m8d6p0zNps187fhBwzY/ii6gxfjob1VxWb919Nk1HUk=
modernc.org/ccgo/v3 v3.12.50/go.mod h1:bu9YIwtg+HXQxBhsRDE+cJjQRuINuT9PUK4orOco/JI=
modernc.org/ccgo/v3 v3.12.51/go.mod h1:gaIIlx4YpmGO2bLye04/yeblmvWEmE4BBBls4aJXFiE=
modernc.org/ccgo/v3 v3.12.53/go.mod h1:8xWGGTFkdFEWBEsUmi+DBjwu/WLy3SSOrqEmKUjMeEg=
modernc.org/ccgo/v3 v3.12.54/go.mod h1:yANKFTm9llTFVX1FqNKHE0aMcQb1fuPJx6p8AcUx+74=
modernc.org/ccgo/v3 v3.12.55/go.mod h1:rsXiIyJi9psOwiBkplOaHye5L4MOOaCjHg1Fxkj7IeU=
modernc.org/ccgo/v3 v3.12.56/go.mod h1:ljeFks3faDseCkr60JMpeDb2GSO3TKAmrzm7q9YOcMU=
modernc.org/ccgo/v3 v3.12.57/go.mod h1:hNSF4DNVgBl8wYHpMvPqQWDQx8luqxDnNGCMM4NFNMc=
modernc.org/ccgo/v3 v3.12.60/go.mod h1:k/Nn0zdO1xHVWjPYVshDeWKqbRWIfif5dtsIOCUVMqM=
modernc.org/ccgo/v3 v3.12.65/go.mod h1:D6hQtKxPNZiY6wDBtehSGKFKmyXn53F8nGTpH+POmS4=
modernc.org/ccgo/v3 v3.12.66/go.mod h1:jUuxlCFZTUZLMV08s7B1ekHX5+LIAurKTTaugUr/EhQ=
modernc.org/ccgo/v3 v3.12.67/go.mod h1:Bll3KwKvGROizP2Xj17GEGOTrlvB1XcVaBrC90ORO84=
modernc.org/ccgo/v3 v3.12.73/go.mod h1:hngkB+nUUqzOf3iqsM48Gf1FZhY599qzVg1iX+BT3cQ=
modernc.org/ccgo/v3 v3.12.81/go.mod h1:p2A1duHoBBg1mFtYvnhAnQyI6vL0uw5PGYLSIgF6rYY=
modernc.org/ccgo/v3 v3.12.82 h1:wudcnJyjLj1aQQCXF3IM9Gz2X6UNjw+afIghzdtn0v8=
modernc.org/ccgo/v3 v3.12.82/go.mod h1:ApbflUfa5BKadjHynCficldU1ghjen84tuM5jRynB7w=
modernc.org/ccorpus v1.11.1/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q=
modernc.org/libc v1.11.0/go.mod h1:2lOfPmj7cz+g1MrPNmX65QCzVxgNq2C5o0jdLY2gAYg=
modernc.org/libc v1.11.2/go.mod h1:ioIyrl3ETkugDO3SGZ+6EOKvlP3zSOycUETe4XM4n8M=
modernc.org/libc v1.11.5/go.mod h1:k3HDCP95A6U111Q5TmG3nAyUcp3kR5YFZTeDS9v8vSU=
modernc.org/libc v1.11.6/go.mod h1:ddqmzR6p5i4jIGK1d/EiSw97LBcE3dK24QEwCFvgNgE=
modernc.org/libc v1.11.11/go.mod h1:lXEp9QOOk4qAYOtL3BmMve99S5Owz7Qyowzvg6LiZso=
modernc.org/libc v1.11.13/go.mod h1:ZYawJWlXIzXy2Pzghaf7YfM8OKacP3eZQI81PDLFdY8=
modernc.org/libc v1.11.16/go.mod h1:+DJquzYi+DMRUtWI1YNxrlQO6TcA5+dRRiq8HWBWRC8=
modernc.org/libc v1.11.19/go.mod h1:e0dgEame6mkydy19KKaVPBeEnyJB4LGNb0bBH1EtQ3I=
modernc.org/libc v1.11.24/go.mod h1:FOSzE0UwookyT1TtCJrRkvsOrX2k38HoInhw+cSCUGk=
modernc.org/libc v1.11.26/go.mod h1:SFjnYi9OSd2W7f4ct622o/PAYqk7KHv6GS8NZULIjKY=
modernc.org/libc v1.11.27/go.mod h1:zmWm6kcFXt/jpzeCgfvUNswM0qke8qVwxqZrnddlDiE=
modernc.org/libc v1.11.28/go.mod h1:Ii4V0fTFcbq3qrv3CNn+OGHAvzqMBvC7dBNyC4vHZlg=
modernc.org/libc v1.11.31/go.mod h1:FpBncUkEAtopRNJj8aRo29qUiyx5AvAlAxzlx9GNaVM=
modernc.org/libc v1.11.34/go.mod h1:+Tzc4hnb1iaX/SKAutJmfzES6awxfU1BPvrrJO0pYLg=
modernc.org/libc v1.11.37/go.mod h1:dCQebOwoO1046yTrfUE5nX1f3YpGZQKNcITUYWlrAWo=
modernc.org/libc v1.11.39/go.mod h1:mV8lJMo2S5A31uD0k1cMu7vrJbSA3J3waQJxpV4iqx8=
modernc.org/libc v1.11.42/go.mod h1:yzrLDU+sSjLE+D4bIhS7q1L5UwXDOw99PLSX0BlZvSQ=
modernc.org/libc v1.11.44/go.mod h1:KFq33jsma7F5WXiYelU8quMJasCCTnHK0mkri4yPHgA=
modernc.org/libc v1.11.45/go.mod h1:Y192orvfVQQYFzCNsn+Xt0Hxt4DiO4USpLNXBlXg/tM=
modernc.org/libc v1.11.47/go.mod h1:tPkE4PzCTW27E6AIKIR5IwHAQKCAtudEIeAV1/SiyBg=
modernc.org/libc v1.11.49/go.mod h1:9JrJuK5WTtoTWIFQ7QjX2Mb/bagYdZdscI3xrvHbXjE=
modernc.org/libc v1.11.51/go.mod h1:R9I8u9TS+meaWLdbfQhq2kFknTW0O3aw3kEMqDDxMaM=
modernc.org/libc v1.11.53/go.mod h1:5ip5vWYPAoMulkQ5XlSJTy12Sz5U6blOQiYasilVPsU=
modernc.org/libc v1.11.54/go.mod h1:S/FVnskbzVUrjfBqlGFIPA5m7UwB3n9fojHhCNfSsnw=
modernc.org/libc v1.11.55/go.mod h1:j2A5YBRm6HjNkoSs/fzZrSxCuwWqcMYTDPLNx0URn3M=
modernc.org/libc v1.11.56/go.mod h1:pakHkg5JdMLt2OgRadpPOTnyRXm/uzu+Yyg/LSLdi18=
modernc.org/libc v1.11.58/go.mod h1:ns94Rxv0OWyoQrDqMFfWwka2BcaF6/61CqJRK9LP7S8=
modernc.org/libc v1.11.70/go.mod h1:DUOmMYe+IvKi9n6Mycyx3DbjfzSKrdr/0Vgt3j7P5gw=
modernc.org/libc v1.11.71/go.mod h1:DUOmMYe+IvKi9n6Mycyx3DbjfzSKrdr/0Vgt3j7P5gw=
modernc.org/libc v1.11.75/go.mod h1:dGRVugT6edz361wmD9gk6ax1AbDSe0x5vji0dGJiPT0=
modernc.org/libc v1.11.82/go.mod h1:NF+Ek1BOl2jeC7lw3a7Jj5PWyHPwWD4aq3wVKxqV1fI=
modernc.org/libc v1.11.86/go.mod h1:ePuYgoQLmvxdNT06RpGnaDKJmDNEkV7ZPKI2jnsvZoE=
modernc.org/libc v1.11.87 h1:PzIzOqtlzMDDcCzJ5cUP6h/Ku6Fa9iyflP2ccTY64aE=
modernc.org/libc v1.11.87/go.mod h1:Qvd5iXTeLhI5PS0XSyqMY99282y+3euapQFxM7jYnpY=
modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc=
modernc.org/memory v1.0.5 h1:XRch8trV7GgvTec2i7jc33YlUI0RKVDBvZ5eZ5m8y14=
modernc.org/memory v1.0.5/go.mod h1:B7OYswTRnfGg+4tDH1t1OeUNnsy2viGTdME4tzd+IjM=
modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.14.2 h1:ohsW2+e+Qe2To1W6GNezzKGwjXwSax6R+CrhRxVaFbE=
modernc.org/sqlite v1.14.2/go.mod h1:yqfn85u8wVOE6ub5UT8VI9JjhrwBUUCNyTACN0h6Sx8=
modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/tcl v1.8.13/go.mod h1:V+q/Ef0IJaNUSECieLU4o+8IScapxnMyFV6i/7uQlAY=
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.2.19/go.mod h1:+ZpP0pc4zz97eukOzW3xagV/lS82IpPN9NGG5pNF9vY=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/builder v0.3.12 h1:ASZYX7fQmy+o8UJdhlLHSW57JDOkM8DNhcAF5d0LiJM=
xorm.io/builder v0.3.12/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.2 h1:uTRRKF2jYzbZ5nsofXVUx6ncMaek+SHjWYtCXyZo1oM=
xorm.io/xorm v1.3.2/go.mod h1:9NbjqdnjX6eyjRRhh01GHm64r6N9shTb/8Ak3YRt8Nw=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU=
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
+7344 -14114
View File
File diff suppressed because it is too large Load Diff
+39 -43
View File
@@ -1,6 +1,6 @@
{
"name": "ezbookkeeping",
"version": "0.2.0",
"version": "0.6.0",
"private": true,
"repository": {
"type": "git",
@@ -12,56 +12,52 @@
"url": "https://github.com/mayswind/ezbookkeeping/issues"
},
"scripts": {
"serve": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve",
"build": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",
"lint": "vue-cli-service lint"
"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"
},
"dependencies": {
"axios": "^1.3.4",
"@mdi/js": "^7.4.47",
"@vuepic/vue-datepicker": "^9.0.1",
"axios": "^1.7.3",
"cbor-js": "^0.1.0",
"core-js": "^3.29.1",
"crypto-js": "^4.1.1",
"framework7": "^5.7.14",
"clipboard": "^2.0.11",
"crypto-js": "^4.2.0",
"dom7": "^4.0.6",
"echarts": "^5.5.1",
"framework7": "^8.3.3",
"framework7-icons": "^5.0.5",
"framework7-vue": "^5.7.14",
"js-cookie": "^3.0.1",
"framework7-vue": "^8.3.3",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"line-awesome": "^1.3.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.41",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"pinia": "^2.2.1",
"register-service-worker": "^1.7.2",
"ua-parser-js": "^1.0.34",
"vue": "^2.7.14",
"vue-clipboard2": "^0.3.3",
"vue-i18n": "^8.28.2",
"vue-pincode-input": "^0.4.0",
"vuex": "^3.6.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",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.6.13"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.5.19",
"@vue/cli-plugin-eslint": "^4.5.19",
"@vue/cli-plugin-pwa": "^4.5.19",
"@vue/cli-service": "^4.5.19",
"babel-eslint": "^10.1.0",
"babel-plugin-component": "^1.1.1",
"eslint": "^6.8.0",
"eslint-plugin-vue": "^6.2.2",
"git-revision-webpack-plugin": "^3.0.6",
"moment-locales-webpack-plugin": "^1.2.0",
"vue-template-compiler": "^2.7.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
"@vitejs/plugin-vue": "^5.0.5",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.27.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"
},
"browserslist": [
"> 1%",
+111 -46
View File
@@ -4,40 +4,51 @@ import (
"sort"
"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/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
// AccountsApi represents account api
type AccountsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
accounts *services.AccountService
}
// Initialize an account api singleton instance
var (
Accounts = &AccountsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
container: duplicatechecker.Container,
},
accounts: services.Accounts,
}
)
// AccountListHandler returns accounts list of current user
func (a *AccountsApi) AccountListHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountListHandler(c *core.WebContext) (any, *errs.Error) {
var accountListReq models.AccountListRequest
err := c.ShouldBindQuery(&accountListReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountListHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountListHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
accounts, err := a.accounts.GetAllAccountsByUid(uid)
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountListHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[accounts.AccountListHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -84,20 +95,20 @@ func (a *AccountsApi) AccountListHandler(c *core.Context) (interface{}, *errs.Er
}
// AccountGetHandler returns one specific account of current user
func (a *AccountsApi) AccountGetHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountGetHandler(c *core.WebContext) (any, *errs.Error) {
var accountGetReq models.AccountGetRequest
err := c.ShouldBindQuery(&accountGetReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountGetHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(uid, accountGetReq.Id)
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountGetReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountGetHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountGetReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountGetHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -127,38 +138,50 @@ func (a *AccountsApi) AccountGetHandler(c *core.Context) (interface{}, *errs.Err
}
// AccountCreateHandler saves a new account by request parameters for current user
func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error) {
var accountCreateReq models.AccountCreateRequest
err := c.ShouldBindJSON(&accountCreateReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[accounts.AccountCreateHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
if accountCreateReq.Category < models.ACCOUNT_CATEGORY_CASH || accountCreateReq.Category > models.ACCOUNT_CATEGORY_CERTIFICATE_OF_DEPOSIT {
log.Warnf(c, "[accounts.AccountCreateHandler] account category invalid, category is %d", accountCreateReq.Category)
return nil, errs.ErrAccountCategoryInvalid
}
if accountCreateReq.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
if len(accountCreateReq.SubAccounts) > 0 {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account cannot have any sub accounts")
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot have any sub-accounts")
return nil, errs.ErrAccountCannotHaveSubAccounts
}
if accountCreateReq.Currency == validators.ParentAccountCurrencyPlaceholder {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account cannot set currency placeholder")
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot set currency placeholder")
return nil, errs.ErrAccountCurrencyInvalid
}
} else if accountCreateReq.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
if len(accountCreateReq.SubAccounts) < 1 {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account does not have any sub accounts")
log.Warnf(c, "[accounts.AccountCreateHandler] account does not have any sub-accounts")
return nil, errs.ErrAccountHaveNoSubAccount
}
if accountCreateReq.Currency != validators.ParentAccountCurrencyPlaceholder {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parent account cannot set currency")
log.Warnf(c, "[accounts.AccountCreateHandler] parent account cannot set currency")
return nil, errs.ErrParentAccountCannotSetCurrency
}
if accountCreateReq.Balance != 0 {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parent account cannot set balance")
log.Warnf(c, "[accounts.AccountCreateHandler] parent account cannot set balance")
return nil, errs.ErrParentAccountCannotSetBalance
}
@@ -166,45 +189,82 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.
subAccount := accountCreateReq.SubAccounts[i]
if subAccount.Category != accountCreateReq.Category {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] category of sub account not equals to parent")
log.Warnf(c, "[accounts.AccountCreateHandler] category of sub-account not equals to parent")
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
}
if subAccount.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] sub account type invalid")
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account type invalid")
return nil, errs.ErrSubAccountTypeInvalid
}
if subAccount.Currency == validators.ParentAccountCurrencyPlaceholder {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] sub account cannot set currency placeholder")
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account cannot set currency placeholder")
return nil, errs.ErrAccountCurrencyInvalid
}
}
} else {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account type invalid, type is %d", accountCreateReq.Type)
log.Warnf(c, "[accounts.AccountCreateHandler] account type invalid, type is %d", accountCreateReq.Type)
return nil, errs.ErrAccountTypeInvalid
}
uid := c.GetCurrentUid()
maxOrderId, err := a.accounts.GetMaxDisplayOrder(uid, accountCreateReq.Category)
maxOrderId, err := a.accounts.GetMaxDisplayOrder(c, uid, accountCreateReq.Category)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[accounts.AccountCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, maxOrderId+1)
childrenAccounts := a.createSubAccountModels(uid, &accountCreateReq)
err = a.accounts.CreateAccounts(mainAccount, childrenAccounts)
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId)
if found {
log.Infof(c, "[accounts.AccountCreateHandler] another account \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
accountId, err := utils.StringToInt64(remark)
if err == nil {
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId)
if err != nil {
log.Errorf(c, "[accounts.AccountCreateHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
mainAccount, exists := accountMap[accountId]
if !exists {
return nil, errs.ErrOperationFailed
}
accountInfoResp := mainAccount.ToAccountInfoResponse()
for i := 0; i < len(accountAndSubAccounts); i++ {
if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId {
subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse()
accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp)
}
}
return accountInfoResp, nil
}
}
}
err = a.accounts.CreateAccounts(c, mainAccount, childrenAccounts, utcOffset)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
log.Errorf(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
log.Infof(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
accountInfoResp := mainAccount.ToAccountInfoResponse()
if len(childrenAccounts) > 0 {
@@ -219,20 +279,25 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.
}
// AccountModifyHandler saves an existed account by request parameters for current user
func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error) {
var accountModifyReq models.AccountModifyRequest
err := c.ShouldBindJSON(&accountModifyReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountModifyHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if accountModifyReq.Category < models.ACCOUNT_CATEGORY_CASH || accountModifyReq.Category > models.ACCOUNT_CATEGORY_CERTIFICATE_OF_DEPOSIT {
log.Warnf(c, "[accounts.AccountModifyHandler] account category invalid, category is %d", accountModifyReq.Category)
return nil, errs.ErrAccountCategoryInvalid
}
uid := c.GetCurrentUid()
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(uid, accountModifyReq.Id)
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountModifyHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -275,14 +340,14 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
return nil, errs.ErrNothingWillBeUpdated
}
err = a.accounts.ModifyAccounts(uid, toUpdateAccounts)
err = a.accounts.ModifyAccounts(c, uid, toUpdateAccounts)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountModifyHandler] failed to update account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountModifyHandler] failed to update account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
log.Infof(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
accountRespMap := make(map[int64]*models.AccountInfoResponse)
@@ -325,34 +390,34 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
}
// AccountHideHandler hides an existed account by request parameters for current user
func (a *AccountsApi) AccountHideHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountHideHandler(c *core.WebContext) (any, *errs.Error) {
var accountHideReq models.AccountHideRequest
err := c.ShouldBindJSON(&accountHideReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountHideHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.accounts.HideAccount(uid, []int64{accountHideReq.Id}, accountHideReq.Hidden)
err = a.accounts.HideAccount(c, uid, []int64{accountHideReq.Id}, accountHideReq.Hidden)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountHideHandler] failed to hide account \"id:%d\" for user \"uid:%d\", because %s", accountHideReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountHideHandler] failed to hide account \"id:%d\" for user \"uid:%d\", because %s", accountHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountHideHandler] user \"uid:%d\" has hidden account \"id:%d\"", uid, accountHideReq.Id)
log.Infof(c, "[accounts.AccountHideHandler] user \"uid:%d\" has hidden account \"id:%d\"", uid, accountHideReq.Id)
return true, nil
}
// AccountMoveHandler moves display order of existed accounts by request parameters for current user
func (a *AccountsApi) AccountMoveHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountMoveHandler(c *core.WebContext) (any, *errs.Error) {
var accountMoveReq models.AccountMoveRequest
err := c.ShouldBindJSON(&accountMoveReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountMoveHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -370,36 +435,36 @@ func (a *AccountsApi) AccountMoveHandler(c *core.Context) (interface{}, *errs.Er
accounts[i] = account
}
err = a.accounts.ModifyAccountDisplayOrders(uid, accounts)
err = a.accounts.ModifyAccountDisplayOrders(c, uid, accounts)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountMoveHandler] failed to move accounts for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[accounts.AccountMoveHandler] failed to move accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountMoveHandler] user \"uid:%d\" has moved accounts", uid)
log.Infof(c, "[accounts.AccountMoveHandler] user \"uid:%d\" has moved accounts", uid)
return true, nil
}
// AccountDeleteHandler deletes an existed account by request parameters for current user
func (a *AccountsApi) AccountDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var accountDeleteReq models.AccountDeleteRequest
err := c.ShouldBindJSON(&accountDeleteReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountDeleteHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.accounts.DeleteAccount(uid, accountDeleteReq.Id)
err = a.accounts.DeleteAccount(c, uid, accountDeleteReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountDeleteHandler] failed to delete account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountDeleteHandler] failed to delete account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountDeleteHandler] user \"uid:%d\" has deleted account \"id:%d\"", uid, accountDeleteReq.Id)
log.Infof(c, "[accounts.AccountDeleteHandler] user \"uid:%d\" has deleted account \"id:%d\"", uid, accountDeleteReq.Id)
return true, nil
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const amapCustomMapStylesUrl = "https://webapi.amap.com/v4/map/styles"
const amapOverseasMapUrl = "https://fmap01.amap.com/v3/vectormap"
const amapRestApiUrl = "https://restapi.amap.com/"
// AmapApiProxy represents amap api proxy
type AmapApiProxy struct {
ApiUsingConfig
}
// Initialize a amap api proxy singleton instance
var (
AmapApis = &AmapApiProxy{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// AmapApiProxyHandler returns amap api response
func (p *AmapApiProxy) AmapApiProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
var targetUrl string
if strings.HasPrefix(c.Request.RequestURI, "/_AMapService/v4/map/styles") {
targetUrl = amapCustomMapStylesUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/v4/map/styles")
} else if strings.HasPrefix(c.Request.RequestURI, "/_AMapService/v3/vectormap") {
targetUrl = amapOverseasMapUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/v3/vectormap")
} else {
targetUrl = amapRestApiUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/")
}
director := func(req *http.Request) {
targetRawUrl := fmt.Sprintf("%s?%s&jscode=%s", targetUrl, req.URL.RawQuery, p.CurrentConfig().AmapApplicationSecret)
targetUrl, _ := url.Parse(targetRawUrl)
oldCookies := req.Cookies()
req.Header.Del("Cookie")
for i := 0; i < len(oldCookies); i++ {
if strings.HasPrefix(oldCookies[i].Name, "ebk_") {
continue
}
req.AddCookie(oldCookies[i])
}
req.URL = targetUrl
req.RequestURI = req.URL.RequestURI()
req.Host = targetUrl.Host
}
return &httputil.ReverseProxy{Director: director}, nil
}
+110 -47
View File
@@ -3,15 +3,19 @@ package api
import (
"github.com/pquerna/otp/totp"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"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/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// AuthorizationsApi represents authorization api
type AuthorizationsApi struct {
ApiUsingConfig
ApiWithUserInfo
users *services.UserService
tokens *services.TokenService
twoFactorAuthorizations *services.TwoFactorAuthorizationService
@@ -20,6 +24,17 @@ type AuthorizationsApi struct {
// Initialize a authorization api singleton instance
var (
Authorizations = &AuthorizationsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiWithUserInfo: ApiWithUserInfo{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
container: avatars.Container,
},
},
users: services.Users,
tokens: services.Tokens,
twoFactorAuthorizations: services.TwoFactorAuthorizations,
@@ -27,35 +42,56 @@ var (
)
// AuthorizeHandler verifies and authorizes current login request
func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
var credential models.UserLoginRequest
err := c.ShouldBindJSON(&credential)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[authorizations.AuthorizeHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrLoginNameOrPasswordInvalid
}
user, err := a.users.GetUserByUsernameOrEmailAndPassword(credential.LoginName, credential.Password)
user, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
return nil, errs.ErrLoginNameOrPasswordWrong
}
err = a.users.UpdateUserLastLoginTime(user.Uid)
if user.Disabled {
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user is disabled", credential.LoginName)
return nil, errs.ErrUserIsDisabled
}
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
hasValidEmailVerifyToken, err := a.tokens.ExistsValidTokenByType(c, user.Uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
if err != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] failed check whether user \"uid:%d\" has valid verify email token, because %s", user.Uid, err.Error())
hasValidEmailVerifyToken = false
}
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user has not verified email", credential.LoginName)
return nil, errs.NewErrorWithContext(errs.ErrEmailIsNotVerified, map[string]any{
"email": user.Email,
"hasValidEmailVerifyToken": hasValidEmailVerifyToken,
})
}
err = a.users.UpdateUserLastLoginTime(c, user.Uid)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to update last login time for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[authorizations.AuthorizeHandler] failed to update last login time for user \"uid:%d\", because %s", user.Uid, err.Error())
}
twoFactorEnable := a.tokens.CurrentConfig().EnableTwoFactor
if twoFactorEnable {
twoFactorEnable, err = a.twoFactorAuthorizations.ExistsTwoFactorSetting(user.Uid)
twoFactorEnable, err = a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, user.Uid)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to check two factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.AuthorizeHandler] failed to check two-factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrSystemError)
}
}
@@ -64,91 +100,106 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *err
var claims *core.UserTokenClaims
if twoFactorEnable {
token, claims, err = a.tokens.CreateRequire2FAToken(user, c)
token, claims, err = a.tokens.CreateRequire2FAToken(c, user)
} else {
token, claims, err = a.tokens.CreateToken(user, c)
token, claims, err = a.tokens.CreateToken(c, user)
}
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.AuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
if !twoFactorEnable {
c.SetTextualToken(token)
}
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
authResp := a.getAuthResponse(token, twoFactorEnable, user)
authResp := a.getAuthResponse(c, token, twoFactorEnable, user)
return authResp, nil
}
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
var credential models.TwoFactorLoginRequest
err := c.ShouldBindJSON(&credential)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrPasscodeInvalid
}
uid := c.GetCurrentUid()
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(uid)
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get two factor setting for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrSystemError)
}
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
return nil, errs.ErrPasscodeInvalid
}
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
return nil, errs.ErrUserNotFound
}
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(oldTokenClaims)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
if user.Disabled {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
token, claims, err := a.tokens.CreateToken(user, c)
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
}
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two-factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt)
authResp := a.getAuthResponse(token, false, user)
authResp := a.getAuthResponse(c, token, false, user)
return authResp, nil
}
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) {
var credential models.TwoFactorRecoveryCodeLoginRequest
err := c.ShouldBindJSON(&credential)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrTwoFactorRecoveryCodeInvalid
}
uid := c.GetCurrentUid()
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two factor setting for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrSystemError)
}
@@ -156,46 +207,58 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont
return nil, errs.ErrTwoFactorIsNotEnabled
}
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
return nil, errs.ErrUserNotFound
}
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(uid, credential.RecoveryCode, user.Salt)
if user.Disabled {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
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)
}
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(oldTokenClaims)
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
}
token, claims, err := a.tokens.CreateToken(user, c)
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt)
log.Infof(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two-factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt)
authResp := a.getAuthResponse(token, false, user)
authResp := a.getAuthResponse(c, token, false, user)
return authResp, nil
}
func (a *AuthorizationsApi) getAuthResponse(token string, need2FA bool, user *models.User) *models.AuthResponse {
func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User) *models.AuthResponse {
return &models.AuthResponse{
Token: token,
Need2FA: need2FA,
User: user.ToUserBasicInfo(),
Token: token,
Need2FA: need2FA,
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()),
}
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"fmt"
"sort"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const internalTransactionPictureUrlFormat = "%spictures/%d.%s"
// ApiUsingConfig represents an api that need to use config
type ApiUsingConfig struct {
container *settings.ConfigContainer
}
// CurrentConfig returns the current config
func (a *ApiUsingConfig) CurrentConfig() *settings.Config {
return a.container.Current
}
// GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model
func (a *ApiUsingConfig) GetTransactionPictureInfoResponse(pictureInfo *models.TransactionPictureInfo) *models.TransactionPictureInfoBasicResponse {
originalUrl := fmt.Sprintf(internalTransactionPictureUrlFormat, a.CurrentConfig().RootUrl, pictureInfo.PictureId, pictureInfo.PictureExtension)
return pictureInfo.ToTransactionPictureInfoBasicResponse(originalUrl)
}
// GetTransactionPictureInfoResponseList returns the view-object list of transaction picture basic info according to the transaction picture model
func (a *ApiUsingConfig) GetTransactionPictureInfoResponseList(pictureInfos []*models.TransactionPictureInfo) models.TransactionPictureInfoBasicResponseSlice {
pictureInfoResps := make(models.TransactionPictureInfoBasicResponseSlice, len(pictureInfos))
for i := 0; i < len(pictureInfos); i++ {
pictureInfoResps[i] = a.GetTransactionPictureInfoResponse(pictureInfos[i])
}
sort.Sort(pictureInfoResps)
return pictureInfoResps
}
// GetAfterRegisterNotificationContent returns the notification content displayed each time users register
func (a *ApiUsingConfig) GetAfterRegisterNotificationContent(userLanguage string, clientLanguage string) string {
language := userLanguage
if language == "" {
language = clientLanguage
}
if !a.container.Current.AfterRegisterNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterRegisterNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterRegisterNotification.DefaultContent
}
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
func (a *ApiUsingConfig) GetAfterLoginNotificationContent(userLanguage string, clientLanguage string) string {
language := userLanguage
if language == "" {
language = clientLanguage
}
if !a.container.Current.AfterLoginNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterLoginNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterLoginNotification.DefaultContent
}
// GetAfterOpenNotificationContent returns the notification content displayed each time users open the app
func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, clientLanguage string) string {
language := userLanguage
if language == "" {
language = clientLanguage
}
if !a.container.Current.AfterOpenNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterOpenNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterOpenNotification.DefaultContent
}
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
type ApiUsingDuplicateChecker struct {
container *duplicatechecker.DuplicateCheckerContainer
}
// GetSubmissionRemark returns whether the same submission has been processed and related remark by the current duplicate checker
func (a *ApiUsingDuplicateChecker) GetSubmissionRemark(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) (bool, string) {
return a.container.GetSubmissionRemark(checkerType, uid, identification)
}
// SetSubmissionRemark saves the identification and remark to in-memory cache by the current duplicate checker
func (a *ApiUsingDuplicateChecker) SetSubmissionRemark(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string) {
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
}
// ApiUsingAvatarProvider represents an api that need to use avatar provider
type ApiUsingAvatarProvider struct {
container *avatars.AvatarProviderContainer
}
// GetAvatarUrl returns the avatar url by the current user avatar provider
func (a *ApiUsingAvatarProvider) GetAvatarUrl(user *models.User) string {
return a.container.GetAvatarUrl(user)
}
// ApiWithUserInfo represents an api that can returns user info
type ApiWithUserInfo struct {
ApiUsingConfig
ApiUsingAvatarProvider
}
// GetUserBasicInfo returns the view-object of user basic info according to the user model
func (a *ApiWithUserInfo) GetUserBasicInfo(user *models.User) *models.UserBasicInfo {
return user.ToUserBasicInfo(a.CurrentConfig().AvatarProvider, a.GetAvatarUrl(user))
}
+154 -102
View File
@@ -19,162 +19,125 @@ const pageCountForDataExport = 1000
// DataManagementsApi represents data management api
type DataManagementsApi struct {
exporter *converters.EzBookKeepingCSVFileExporter
ApiUsingConfig
tokens *services.TokenService
users *services.UserService
accounts *services.AccountService
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
pictures *services.TransactionPictureService
templates *services.TransactionTemplateService
}
// Initialize a data management api singleton instance
var (
DataManagements = &DataManagementsApi{
exporter: &converters.EzBookKeepingCSVFileExporter{},
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
tokens: services.Tokens,
users: services.Users,
accounts: services.Accounts,
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
pictures: services.TransactionPictures,
templates: services.TransactionTemplates,
}
)
// ExportDataHandler returns exported data in csv format
func (a *DataManagementsApi) ExportDataHandler(c *core.Context) ([]byte, string, *errs.Error) {
if !settings.Container.Current.EnableDataExport {
return nil, "", errs.ErrDataExportNotAllowed
}
// ExportDataToEzbookkeepingCSVHandler returns exported data in csv format
func (a *DataManagementsApi) ExportDataToEzbookkeepingCSVHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
return a.getExportedFileContent(c, "csv")
}
timezone := time.Local
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.WarnfWithRequestId(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
} else {
timezone = time.FixedZone("Client Timezone", int(utcOffset)*60)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
if err != nil {
if !errs.IsCustomError(err) {
log.WarnfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, "", errs.ErrUserNotFound
}
accounts, err := a.accounts.GetAllAccountsByUid(uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
categories, err := a.categories.GetAllCategoriesByUid(uid, 0, -1)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tags, err := a.tags.GetAllTagsByUid(uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tagIndexs, err := a.tags.GetAllTagIdsOfAllTransactions(uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
accountMap := a.accounts.GetAccountMapByList(accounts)
categoryMap := a.categories.GetCategoryMapByList(categories)
tagMap := a.tags.GetTagMapByList(tags)
allTransactions, err := a.transactions.GetAllTransactions(uid, pageCountForDataExport, true)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
result, err := a.exporter.ToExportedContent(uid, timezone, allTransactions, accountMap, categoryMap, tagMap, tagIndexs)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
fileName := a.getFileName(user, timezone)
return result, fileName, nil
// ExportDataToEzbookkeepingTSVHandler returns exported data in csv format
func (a *DataManagementsApi) ExportDataToEzbookkeepingTSVHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
return a.getExportedFileContent(c, "tsv")
}
// DataStatisticsHandler returns user data statistics
func (a *DataManagementsApi) DataStatisticsHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
totalAccountCount, err := a.accounts.GetTotalAccountCountByUid(uid)
totalAccountCount, err := a.accounts.GetTotalAccountCountByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total account count for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total account count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionCategoryCount, err := a.categories.GetTotalCategoryCountByUid(uid)
totalTransactionCategoryCount, err := a.categories.GetTotalCategoryCountByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total transaction category count for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction category count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionTagCount, err := a.tags.GetTotalTagCountByUid(uid)
totalTransactionTagCount, err := a.tags.GetTotalTagCountByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total transaction tag count for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction tag count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionCount, err := a.transactions.GetTotalTransactionCountByUid(uid)
totalTransactionCount, err := a.transactions.GetTotalTransactionCountByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total transaction count for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionPictureCount, err := a.pictures.GetTotalTransactionPicturesCountByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction picture count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionTemplateCount, err := a.templates.GetTotalNormalTemplateCountByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction template count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalScheduledTransactionCount, err := a.templates.GetTotalScheduledTemplateCountByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total scheduled transaction count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
dataStatisticsResp := &models.DataStatisticsResponse{
TotalAccountCount: totalAccountCount,
TotalTransactionCategoryCount: totalTransactionCategoryCount,
TotalTransactionTagCount: totalTransactionTagCount,
TotalTransactionCount: totalTransactionCount,
TotalAccountCount: totalAccountCount,
TotalTransactionCategoryCount: totalTransactionCategoryCount,
TotalTransactionTagCount: totalTransactionTagCount,
TotalTransactionCount: totalTransactionCount,
TotalTransactionPictureCount: totalTransactionPictureCount,
TotalTransactionTemplateCount: totalTransactionTemplateCount,
TotalScheduledTransactionCount: totalScheduledTransactionCount,
}
return dataStatisticsResp, nil
}
// ClearDataHandler deletes all user data
func (a *DataManagementsApi) ClearDataHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Error) {
var clearDataReq models.ClearDataRequest
err := c.ShouldBindJSON(&clearDataReq)
if err != nil {
log.WarnfWithRequestId(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.WarnfWithRequestId(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
@@ -184,36 +147,125 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.Context) (interface{}, *er
return nil, errs.ErrUserPasswordWrong
}
err = a.transactions.DeleteAllTransactions(uid)
err = a.templates.DeleteAllTemplates(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction templates, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.categories.DeleteAllCategories(uid)
err = a.transactions.DeleteAllTransactions(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.tags.DeleteAllTags(uid)
err = a.categories.DeleteAllCategories(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
err = a.tags.DeleteAllTags(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
return true, nil
}
func (a *DataManagementsApi) getFileName(user *models.User, timezone *time.Location) string {
func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType string) ([]byte, string, *errs.Error) {
if !a.CurrentConfig().EnableDataExport {
return nil, "", errs.ErrDataExportNotAllowed
}
timezone := time.Local
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
} else {
timezone = time.FixedZone("Client Timezone", int(utcOffset)*60)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[data_managements.ExportDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, "", errs.ErrUserNotFound
}
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
categories, err := a.categories.GetAllCategoriesByUid(c, uid, 0, -1)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tags, err := a.tags.GetAllTagsByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tagIndexes, err := a.tags.GetAllTagIdsMapOfAllTransactions(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
accountMap := a.accounts.GetAccountMapByList(accounts)
categoryMap := a.categories.GetCategoryMapByList(categories)
tagMap := a.tags.GetTagMapByList(tags)
allTransactions, err := a.transactions.GetAllTransactions(c, uid, pageCountForDataExport, true)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
dataExporter := converters.GetTransactionDataExporter(fileType)
if dataExporter == nil {
return nil, "", errs.ErrNotImplemented
}
result, err := dataExporter.ToExportedContent(c, uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
fileName := a.getFileName(user, timezone, fileType)
return result, fileName, nil
}
func (a *DataManagementsApi) getFileName(user *models.User, timezone *time.Location, fileExtension string) string {
currentTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(time.Now().Unix(), timezone)
currentTime = strings.Replace(currentTime, "-", "_", -1)
currentTime = strings.Replace(currentTime, " ", "_", -1)
currentTime = strings.Replace(currentTime, ":", "_", -1)
return fmt.Sprintf("%s_%s.csv", user.Username, currentTime)
return fmt.Sprintf("%s_%s.%s", user.Username, currentTime, fileExtension)
}
+2 -2
View File
@@ -14,11 +14,11 @@ var (
)
// ApiNotFound returns api not found error
func (a *DefaultApi) ApiNotFound(c *core.Context) (interface{}, *errs.Error) {
func (a *DefaultApi) ApiNotFound(c *core.WebContext) (any, *errs.Error) {
return nil, errs.ErrApiNotFound
}
// MethodNotAllowed returns method not allowed error
func (a *DefaultApi) MethodNotAllowed(c *core.Context) (interface{}, *errs.Error) {
func (a *DefaultApi) MethodNotAllowed(c *core.WebContext) (any, *errs.Error) {
return nil, errs.ErrMethodNotAllowed
}
+30 -8
View File
@@ -1,6 +1,8 @@
package api
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"sort"
@@ -12,18 +14,25 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// ExchangeRatesApi represents exchange rate api
type ExchangeRatesApi struct{}
type ExchangeRatesApi struct {
ApiUsingConfig
}
// Initialize a exchange rate api singleton instance
var (
ExchangeRates = &ExchangeRatesApi{}
ExchangeRates = &ExchangeRatesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// LatestExchangeRateHandler returns latest exchange rate data
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
dataSource := exchangerates.Container.Current
if dataSource == nil {
@@ -32,23 +41,36 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (interface
uid := c.GetCurrentUid()
transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, a.CurrentConfig().ExchangeRatesProxy)
if a.CurrentConfig().ExchangeRatesSkipTLSVerify {
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
client := &http.Client{
Timeout: time.Duration(settings.Container.Current.ExchangeRatesRequestTimeout) * time.Millisecond,
Transport: transport,
Timeout: time.Duration(a.CurrentConfig().ExchangeRatesRequestTimeout) * time.Millisecond,
}
urls := dataSource.GetRequestUrls()
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(urls))
for i := 0; i < len(urls); i++ {
resp, err := client.Get(urls[i])
req, _ := http.NewRequest("GET", urls[i], nil)
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s ", settings.Version))
resp, err := client.Do(req)
if err != nil {
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
if resp.StatusCode != 200 {
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not 200", uid)
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not 200", uid)
return nil, errs.ErrFailedToRequestRemoteApi
}
@@ -57,7 +79,7 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (interface
exchangeRateResp, err := dataSource.Parse(c, body)
if err != nil {
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi)
}
+156
View File
@@ -0,0 +1,156 @@
package api
import (
"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/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// ForgetPasswordsApi represents user forget password api
type ForgetPasswordsApi struct {
ApiUsingConfig
users *services.UserService
tokens *services.TokenService
forgetPasswords *services.ForgetPasswordService
}
// Initialize a user api singleton instance
var (
ForgetPasswords = &ForgetPasswordsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
users: services.Users,
tokens: services.Tokens,
forgetPasswords: services.ForgetPasswords,
}
)
// UserForgetPasswordRequestHandler generates password reset link and send user an email with this link
func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext) (any, *errs.Error) {
var request models.ForgetPasswordRequest
err := c.ShouldBindJSON(&request)
if err != nil {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrEmailIsEmptyOrInvalid
}
user, err := a.users.GetUserByEmail(c, request.Email)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
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
}
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreatePasswordResetToken(c, user)
if err != nil {
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
go func() {
err = a.forgetPasswords.SendPasswordResetEmail(c, user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
return true, nil
}
// UserResetPasswordHandler resets user password by request parameters
func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any, *errs.Error) {
var request models.PasswordResetRequest
err := c.ShouldBindJSON(&request)
if err != nil {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
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
}
if user.Email != request.Email {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
return nil, errs.ErrEmptyIsInvalid
}
if a.users.IsPasswordEqualsUserPassword(request.Password, user) {
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
if err != nil {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke password reset token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
}
return nil, errs.ErrNewPasswordEqualsOldInvalid
}
userNew := &models.User{
Uid: user.Uid,
Salt: user.Salt,
Password: request.Password,
}
_, _, err = a.users.UpdateUser(c, userNew, false)
if err != nil {
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
now := time.Now().Unix()
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
if err == nil {
log.Infof(c, "[forget_passwords.UserResetPasswordHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
return true, nil
}
+1 -1
View File
@@ -15,7 +15,7 @@ var (
)
// HealthStatusHandler returns the health status of current service
func (a *HealthsApi) HealthStatusHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
result := make(map[string]string)
result["version"] = settings.Version
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"net/http"
"net/http/httputil"
"net/url"
"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 openStreetMapTileImageUrlFormat = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" // https://tile.openstreetmap.org/{z}/{x}/{y}.png
const openStreetMapHumanitarianStyleTileImageUrlFormat = "https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png" // https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png
const openTopoMapTileImageUrlFormat = "https://tile.opentopomap.org/{z}/{x}/{y}.png" // https://tile.opentopomap.org/{z}/{x}/{y}.png
const opnvKarteMapTileImageUrlFormat = "https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png" // https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png
const cyclOSMMapTileImageUrlFormat = "https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png" // https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png
const cartoDBMapTileImageUrlFormat = "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{scale}.png" // https://{s}.basemaps.cartocdn.com/{style}/{z}/{x}/{y}{scale}.png
const tomtomMapTileImageUrlFormat = "https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png" // https://api.tomtom.com/map/{versionNumber}/tile/{layer}/{style}/{z}/{x}/{y}.png?key={key}&language={language}
const tianDiTuMapTileImageUrlFormat = "https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}" // https://t{s}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}
const tianDiTuMapAnnotationUrlFormat = "https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}" // https://t{s}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}
// MapImageProxy represents map image proxy
type MapImageProxy struct {
ApiUsingConfig
}
// Initialize a map image proxy singleton instance
var (
MapImages = &MapImageProxy{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// MapTileImageProxyHandler returns map tile image
func (p *MapImageProxy) MapTileImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
return p.mapImageProxyHandler(c, func(c *core.WebContext, mapProvider string) (string, *errs.Error) {
if mapProvider == settings.OpenStreetMapProvider {
return openStreetMapTileImageUrlFormat, nil
} else if mapProvider == settings.OpenStreetMapHumanitarianStyleProvider {
return openStreetMapHumanitarianStyleTileImageUrlFormat, nil
} else if mapProvider == settings.OpenTopoMapProvider {
return openTopoMapTileImageUrlFormat, nil
} else if mapProvider == settings.OPNVKarteMapProvider {
return opnvKarteMapTileImageUrlFormat, nil
} else if mapProvider == settings.CyclOSMMapProvider {
return cyclOSMMapTileImageUrlFormat, nil
} else if mapProvider == settings.CartoDBMapProvider {
return cartoDBMapTileImageUrlFormat, nil
} else if mapProvider == settings.TomTomMapProvider {
targetUrl := tomtomMapTileImageUrlFormat + "?key=" + p.CurrentConfig().TomTomMapAPIKey
language := c.Query("language")
if language != "" {
targetUrl = targetUrl + "&language=" + language
}
return targetUrl, nil
} else if mapProvider == settings.TianDiTuProvider {
return tianDiTuMapTileImageUrlFormat + "&tk=" + p.CurrentConfig().TianDiTuAPIKey, nil
} else if mapProvider == settings.CustomProvider {
return p.CurrentConfig().CustomMapTileServerTileLayerUrl, nil
}
return "", errs.ErrParameterInvalid
})
}
// MapAnnotationImageProxyHandler returns map annotation image
func (p *MapImageProxy) MapAnnotationImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
return p.mapImageProxyHandler(c, func(c *core.WebContext, mapProvider string) (string, *errs.Error) {
if mapProvider == settings.TianDiTuProvider {
return tianDiTuMapAnnotationUrlFormat + "&tk=" + p.CurrentConfig().TianDiTuAPIKey, nil
} else if mapProvider == settings.CustomProvider {
return p.CurrentConfig().CustomMapTileServerAnnotationLayerUrl, nil
}
return "", errs.ErrParameterInvalid
})
}
func (p *MapImageProxy) mapImageProxyHandler(c *core.WebContext, fn func(c *core.WebContext, mapProvider string) (string, *errs.Error)) (*httputil.ReverseProxy, *errs.Error) {
mapProvider := strings.Replace(c.Query("provider"), "-", "_", -1)
targetUrl := ""
if mapProvider != p.CurrentConfig().MapProvider {
return nil, errs.ErrMapProviderNotCurrent
}
zoomLevel := c.Param("zoomLevel")
coordinateX := c.Param("coordinateX")
fileName := c.Param("fileName")
fileNameParts := strings.Split(fileName, ".")
coordinateY := fileNameParts[0]
scale := c.Query("scale")
if len(fileNameParts) != 2 || fileNameParts[len(fileNameParts)-1] != "png" {
return nil, errs.ErrImageExtensionNotSupported
}
var err *errs.Error
targetUrl, err = fn(c, mapProvider)
if err != nil {
return nil, err
}
transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, p.CurrentConfig().MapProxy)
director := func(req *http.Request) {
imageRawUrl := targetUrl
imageRawUrl = strings.Replace(imageRawUrl, "{z}", zoomLevel, -1)
imageRawUrl = strings.Replace(imageRawUrl, "{x}", coordinateX, -1)
imageRawUrl = strings.Replace(imageRawUrl, "{y}", coordinateY, -1)
imageRawUrl = strings.Replace(imageRawUrl, "{scale}", scale, -1)
imageUrl, _ := url.Parse(imageRawUrl)
req.URL = imageUrl
req.RequestURI = req.URL.RequestURI()
req.Host = imageUrl.Host
}
return &httputil.ReverseProxy{
Transport: transport,
Director: director,
}, nil
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"bytes"
"image/png"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/qr"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const (
qrCodeDefaultWidth int = 320
qrCodeDefaultHeight int = 320
)
// QrCodesApi represents qrcode generator api
type QrCodesApi struct {
ApiUsingConfig
}
// Initialize a qrcode generator api singleton instance
var (
QrCodes = &QrCodesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// MobileUrlQrCodeHandler returns a mobile url qr code image
func (a *QrCodesApi) MobileUrlQrCodeHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
fullUrl := a.CurrentConfig().RootUrl + "mobile"
data, err := a.generateUrlQrCode(c, fullUrl)
if err != nil {
return nil, "", errs.ErrOperationFailed
}
return data, "image/png", nil
}
func (a *QrCodesApi) generateUrlQrCode(c *core.WebContext, url string) ([]byte, *errs.Error) {
qrCodeImg, _ := qr.Encode(url, qr.M, qr.Auto)
qrCodeImg, _ = barcode.Scale(qrCodeImg, qrCodeDefaultWidth, qrCodeDefaultHeight)
imgData := &bytes.Buffer{}
if err := png.Encode(imgData, qrCodeImg); err != nil {
return nil, errs.ErrOperationFailed
}
return imgData.Bytes(), nil
}
+88 -45
View File
@@ -2,17 +2,22 @@ package api
import (
"sort"
"time"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"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/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// TokensApi represents token api
type TokensApi struct {
ApiUsingConfig
ApiWithUserInfo
tokens *services.TokenService
users *services.UserService
}
@@ -20,18 +25,29 @@ type TokensApi struct {
// Initialize a token api singleton instance
var (
Tokens = &TokensApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiWithUserInfo: ApiWithUserInfo{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
container: avatars.Container,
},
},
tokens: services.Tokens,
users: services.Users,
}
)
// TokenListHandler returns available token list of current user
func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tokens, err := a.tokens.GetAllUnexpiredNormalTokensByUid(uid)
tokens, err := a.tokens.GetAllUnexpiredNormalTokensByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -44,11 +60,10 @@ func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error)
TokenId: a.tokens.GenerateTokenId(token),
TokenType: token.TokenType,
UserAgent: token.UserAgent,
CreatedAt: token.CreatedUnixTime,
ExpiredAt: token.ExpiredUnixTime,
LastSeen: token.LastSeenUnixTime,
}
if utils.Int64ToString(token.Uid) == claims.Id && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
if token.Uid == claims.Uid && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
tokenResp.IsCurrent = true
}
@@ -61,52 +76,45 @@ func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error)
}
// TokenRevokeCurrentHandler revokes current token of current user
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.Context) (interface{}, *errs.Error) {
_, claims, err := a.tokens.ParseToken(c)
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
_, claims, err := a.tokens.ParseTokenByHeader(c)
if err != nil {
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid, err := utils.StringToInt64(claims.Id)
if err != nil {
log.WarnfWithRequestId(c, "[tokens.TokenRevokeCurrentHandler] parse user id failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
}
userTokenId, err := utils.StringToInt64(claims.UserTokenId)
if err != nil {
log.WarnfWithRequestId(c, "[tokens.TokenRevokeCurrentHandler] parse user token id failed, because %s", err.Error())
log.Warnf(c, "[tokens.TokenRevokeCurrentHandler] parse user token id failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
tokenRecord := &models.TokenRecord{
Uid: uid,
Uid: claims.Uid,
UserTokenId: userTokenId,
CreatedUnixTime: claims.IssuedAt,
}
tokenId := a.tokens.GenerateTokenId(tokenRecord)
err = a.tokens.DeleteToken(tokenRecord)
err = a.tokens.DeleteToken(c, tokenRecord)
if err != nil {
log.ErrorfWithRequestId(c, "[token.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
log.Errorf(c, "[token.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[token.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenId)
log.Infof(c, "[token.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", claims.Uid, tokenId)
return true, nil
}
// TokenRevokeHandler revokes specific token of current user
func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
var tokenRevokeReq models.TokenRevokeRequest
err := c.ShouldBindJSON(&tokenRevokeReq)
if err != nil {
log.WarnfWithRequestId(c, "[tokens.TokenRevokeHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[tokens.TokenRevokeHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -114,7 +122,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Erro
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[token.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
log.Errorf(c, "[token.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
}
return nil, errs.Or(err, errs.ErrInvalidTokenId)
@@ -123,28 +131,28 @@ func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Erro
uid := c.GetCurrentUid()
if tokenRecord.Uid != uid {
log.WarnfWithRequestId(c, "[token.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
log.Warnf(c, "[token.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
return nil, errs.ErrInvalidTokenId
}
err = a.tokens.DeleteToken(tokenRecord)
err = a.tokens.DeleteToken(c, tokenRecord)
if err != nil {
log.ErrorfWithRequestId(c, "[token.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
log.Errorf(c, "[token.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[token.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
log.Infof(c, "[token.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
return true, nil
}
// TokenRevokeAllHandler revokes all tokens of current user except current token
func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tokens, err := a.tokens.GetAllTokensByUid(uid)
tokens, err := a.tokens.GetAllTokensByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[tokens.TokenRevokeAllHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -154,7 +162,7 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (interface{}, *errs.E
for i := 0; i < len(tokens); i++ {
token := tokens[i]
if utils.Int64ToString(token.Uid) == claims.Id && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
if token.Uid == claims.Uid && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
currentTokenIndex = i
break
}
@@ -162,35 +170,68 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (interface{}, *errs.E
tokens = append(tokens[:currentTokenIndex], tokens[currentTokenIndex+1:]...)
err = a.tokens.DeleteTokens(uid, tokens)
err = a.tokens.DeleteTokens(c, uid, tokens)
if err != nil {
log.ErrorfWithRequestId(c, "[token.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[token.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
log.Infof(c, "[token.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
return true, nil
}
// TokenRefreshHandler refresh current token of current user
func (a *TokensApi) TokenRefreshHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.WarnfWithRequestId(c, "[token.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
log.Warnf(c, "[token.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
token, claims, err := a.tokens.CreateToken(user, c)
now := time.Now().Unix()
oldTokenClaims := c.GetTokenClaims()
if now-oldTokenClaims.IssuedAt < int64(a.CurrentConfig().TokenMinRefreshInterval) {
log.Infof(c, "[token.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid)
userTokenId, err := utils.StringToInt64(oldTokenClaims.UserTokenId)
if err != nil {
log.Warnf(c, "[tokens.TokenRefreshHandler] parse user token id failed, because %s", err.Error())
} else {
tokenRecord := &models.TokenRecord{
Uid: oldTokenClaims.Uid,
UserTokenId: userTokenId,
CreatedUnixTime: oldTokenClaims.IssuedAt,
}
tokenId := a.tokens.GenerateTokenId(tokenRecord)
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
if err != nil {
log.Warnf(c, "[token.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
}
}
refreshResp := &models.TokenRefreshResponse{
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
}
return refreshResp, nil
}
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[token.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[token.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrTokenGenerating)
}
oldTokenClaims := c.GetTokenClaims()
oldUserTokenId, _ := utils.StringToInt64(oldTokenClaims.UserTokenId)
oldTokenRecord := &models.TokenRecord{
Uid: uid,
@@ -198,14 +239,16 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (interface{}, *errs.Err
CreatedUnixTime: oldTokenClaims.IssuedAt,
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[token.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[token.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
refreshResp := &models.TokenRefreshResponse{
NewToken: token,
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
User: user.ToUserBasicInfo(),
NewToken: token,
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
}
return refreshResp, nil
+242 -162
View File
@@ -3,40 +3,53 @@ package api
import (
"sort"
"github.com/gin-gonic/gin/binding"
"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/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// TransactionCategoriesApi represents transaction category api
type TransactionCategoriesApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
categories *services.TransactionCategoryService
}
// Initialize a transaction category api singleton instance
var (
TransactionCategories = &TransactionCategoriesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
container: duplicatechecker.Container,
},
categories: services.TransactionCategories,
}
)
// CategoryListHandler returns transaction category list of current user
func (a *TransactionCategoriesApi) CategoryListHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryListHandler(c *core.WebContext) (any, *errs.Error) {
var categoryListReq models.TransactionCategoryListRequest
err := c.ShouldBindQuery(&categoryListReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryListHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_categories.CategoryListHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
categories, err := a.categories.GetAllCategoriesByUid(uid, categoryListReq.Type, categoryListReq.ParentId)
categories, err := a.categories.GetAllCategoriesByUid(c, uid, categoryListReq.Type, categoryListReq.ParentId)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryListHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryListHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -44,20 +57,20 @@ func (a *TransactionCategoriesApi) CategoryListHandler(c *core.Context) (interfa
}
// CategoryGetHandler returns one specific transaction category of current user
func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.WebContext) (any, *errs.Error) {
var categoryGetReq models.TransactionCategoryGetRequest
err := c.ShouldBindQuery(&categoryGetReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryGetHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_categories.CategoryGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
category, err := a.categories.GetCategoryByCategoryId(uid, categoryGetReq.Id)
category, err := a.categories.GetCategoryByCategoryId(c, uid, categoryGetReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryGetHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryGetReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryGetHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -67,37 +80,37 @@ func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (interfac
}
// CategoryCreateHandler saves a new transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.WebContext) (any, *errs.Error) {
var categoryCreateReq models.TransactionCategoryCreateRequest
err := c.ShouldBindJSON(&categoryCreateReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if categoryCreateReq.Type < models.CATEGORY_TYPE_INCOME || categoryCreateReq.Type > models.CATEGORY_TYPE_TRANSFER {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] category type invalid, type is %d", categoryCreateReq.Type)
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] category type invalid, type is %d", categoryCreateReq.Type)
return nil, errs.ErrTransactionCategoryTypeInvalid
}
uid := c.GetCurrentUid()
if categoryCreateReq.ParentId > 0 {
parentCategory, err := a.categories.GetCategoryByCategoryId(uid, categoryCreateReq.ParentId)
parentCategory, err := a.categories.GetCategoryByCategoryId(c, uid, categoryCreateReq.ParentId)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get parent category \"id:%d\" for user \"uid:%d\", because %s", categoryCreateReq.ParentId, uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get parent category \"id:%d\" for user \"uid:%d\", because %s", categoryCreateReq.ParentId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if parentCategory == nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" does not exist for user \"uid:%d\"", categoryCreateReq.ParentId, uid)
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" does not exist for user \"uid:%d\"", categoryCreateReq.ParentId, uid)
return nil, errs.ErrParentTransactionCategoryNotFound
}
if parentCategory.ParentCategoryId > 0 {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" has another parent category \"id:%d\" for user \"uid:%d\"", parentCategory.CategoryId, parentCategory.ParentCategoryId, uid)
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" has another parent category \"id:%d\" for user \"uid:%d\"", parentCategory.CategoryId, parentCategory.ParentCategoryId, uid)
return nil, errs.ErrCannotAddToSecondaryTransactionCategory
}
}
@@ -105,44 +118,243 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (inter
var maxOrderId int32
if categoryCreateReq.ParentId <= 0 {
maxOrderId, err = a.categories.GetMaxDisplayOrder(uid, categoryCreateReq.Type)
maxOrderId, err = a.categories.GetMaxDisplayOrder(c, uid, categoryCreateReq.Type)
} else {
maxOrderId, err = a.categories.GetMaxSubCategoryDisplayOrder(uid, categoryCreateReq.Type, categoryCreateReq.ParentId)
maxOrderId, err = a.categories.GetMaxSubCategoryDisplayOrder(c, uid, categoryCreateReq.Type, categoryCreateReq.ParentId)
}
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
category := a.createNewCategoryModel(uid, &categoryCreateReq, maxOrderId+1)
err = a.categories.CreateCategory(category)
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && categoryCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId)
if found {
log.Infof(c, "[transaction_categories.CategoryCreateHandler] another category \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
categoryId, err := utils.StringToInt64(remark)
if err == nil {
category, err = a.categories.GetCategoryByCategoryId(c, uid, categoryId)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get existed category \"id:%d\" for user \"uid:%d\", because %s", categoryId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
categoryResp := category.ToTransactionCategoryInfoResponse()
return categoryResp, nil
}
}
}
err = a.categories.CreateCategory(c, category)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to create category \"id:%d\" for user \"uid:%d\", because %s", category.CategoryId, uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to create category \"id:%d\" for user \"uid:%d\", because %s", category.CategoryId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
log.Infof(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId))
categoryResp := category.ToTransactionCategoryInfoResponse()
return categoryResp, nil
}
// CategoryCreateBatchHandler saves some new transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.WebContext) (any, *errs.Error) {
var categoryCreateBatchReq models.TransactionCategoryCreateBatchRequest
err := c.ShouldBindJSON(&categoryCreateBatchReq)
err := c.ShouldBindBodyWith(&categoryCreateBatchReq, binding.JSON)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_categories.CategoryCreateBatchHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
categories, err := a.createBatchCategories(c, uid, &categoryCreateBatchReq)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return a.getTransactionCategoryListByTypeResponse(categories, 0)
}
// CategoryModifyHandler saves an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (any, *errs.Error) {
var categoryModifyReq models.TransactionCategoryModifyRequest
err := c.ShouldBindJSON(&categoryModifyReq)
if err != nil {
log.Warnf(c, "[transaction_categories.CategoryModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
category, err := a.categories.GetCategoryByCategoryId(c, uid, categoryModifyReq.Id)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newCategory := &models.TransactionCategory{
CategoryId: category.CategoryId,
Uid: uid,
ParentCategoryId: categoryModifyReq.ParentId,
Name: categoryModifyReq.Name,
Icon: categoryModifyReq.Icon,
Color: categoryModifyReq.Color,
Comment: categoryModifyReq.Comment,
Hidden: categoryModifyReq.Hidden,
}
if newCategory.ParentCategoryId == category.ParentCategoryId &&
newCategory.Name == category.Name &&
newCategory.Icon == category.Icon &&
newCategory.Color == category.Color &&
newCategory.Comment == category.Comment &&
newCategory.Hidden == category.Hidden {
return nil, errs.ErrNothingWillBeUpdated
}
if category.ParentCategoryId == 0 && newCategory.ParentCategoryId != 0 {
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionCategoryToSecondary)
}
if category.ParentCategoryId != 0 && newCategory.ParentCategoryId == 0 {
return nil, errs.Or(err, errs.ErrNotAllowChangeSecondaryTransactionCategoryToPrimary)
}
if newCategory.ParentCategoryId != category.ParentCategoryId {
fromPrimaryCategory, err := a.categories.GetCategoryByCategoryId(c, uid, category.ParentCategoryId)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get old primary category \"id:%d\" of category \"id:%d\" for user \"uid:%d\", because %s", category.ParentCategoryId, categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
toPrimaryCategory, err := a.categories.GetCategoryByCategoryId(c, uid, newCategory.ParentCategoryId)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get new primary category \"id:%d\" of category \"id:%d\" for user \"uid:%d\", because %s", newCategory.ParentCategoryId, categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if fromPrimaryCategory.Type != toPrimaryCategory.Type {
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionType)
}
if toPrimaryCategory.ParentCategoryId != 0 {
return nil, errs.Or(err, errs.ErrNotAllowUseSecondaryTransactionAsPrimaryCategory)
}
}
err = a.categories.ModifyCategory(c, newCategory)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to update category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
newCategory.Type = category.Type
newCategory.DisplayOrder = category.DisplayOrder
categoryResp := newCategory.ToTransactionCategoryInfoResponse()
return categoryResp, nil
}
// CategoryHideHandler hides an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.WebContext) (any, *errs.Error) {
var categoryHideReq models.TransactionCategoryHideRequest
err := c.ShouldBindJSON(&categoryHideReq)
if err != nil {
log.Warnf(c, "[transaction_categories.CategoryHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.categories.HideCategory(c, uid, []int64{categoryHideReq.Id}, categoryHideReq.Hidden)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryHideHandler] failed to hide category \"id:%d\" for user \"uid:%d\", because %s", categoryHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_categories.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, categoryHideReq.Id)
return true, nil
}
// CategoryMoveHandler moves display order of existed transaction categories by request parameters for current user
func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.WebContext) (any, *errs.Error) {
var categoryMoveReq models.TransactionCategoryMoveRequest
err := c.ShouldBindJSON(&categoryMoveReq)
if err != nil {
log.Warnf(c, "[transaction_categories.CategoryMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
categories := make([]*models.TransactionCategory, len(categoryMoveReq.NewDisplayOrders))
for i := 0; i < len(categoryMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := categoryMoveReq.NewDisplayOrders[i]
category := &models.TransactionCategory{
Uid: uid,
CategoryId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
categories[i] = category
}
err = a.categories.ModifyCategoryDisplayOrders(c, uid, categories)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryMoveHandler] failed to move categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_categories.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
return true, nil
}
// CategoryDeleteHandler deletes an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var categoryDeleteReq models.TransactionCategoryDeleteRequest
err := c.ShouldBindJSON(&categoryDeleteReq)
if err != nil {
log.Warnf(c, "[transaction_categories.CategoryDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.categories.DeleteCategory(c, uid, categoryDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryDeleteHandler] failed to delete category \"id:%d\" for user \"uid:%d\", because %s", categoryDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_categories.CategoryDeleteHandler] user \"uid:%d\" has deleted category \"id:%d\"", uid, categoryDeleteReq.Id)
return true, nil
}
func (a *TransactionCategoriesApi) createBatchCategories(c *core.WebContext, uid int64, categoryCreateBatchReq *models.TransactionCategoryCreateBatchRequest) ([]*models.TransactionCategory, error) {
var err error
categoryTypeMaxOrderMap := make(map[models.TransactionCategoryType]int32)
categoriesMap := make(map[*models.TransactionCategory][]*models.TransactionCategory)
categoriesMap[nil] = make([]*models.TransactionCategory, len(categoryCreateBatchReq.Categories))
@@ -153,10 +365,10 @@ func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.Context) (
var maxOrderId, exists = categoryTypeMaxOrderMap[categoryCreateReq.Type]
if !exists {
maxOrderId, err = a.categories.GetMaxDisplayOrder(uid, categoryCreateReq.Type)
maxOrderId, err = a.categories.GetMaxDisplayOrder(c, uid, categoryCreateReq.Type)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
}
@@ -181,148 +393,16 @@ func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.Context) (
totalCount++
}
categories, err := a.categories.CreateCategories(uid, categoriesMap)
categories, err := a.categories.CreateCategories(c, uid, categoriesMap)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] failed to create categories for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_categories.createBatchCategories] failed to create categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] user \"uid:%d\" has created categoroies successfully", uid)
log.Infof(c, "[transaction_categories.createBatchCategories] user \"uid:%d\" has created categories successfully", uid)
return a.getTransactionCategoryListByTypeResponse(categories, 0)
}
// CategoryModifyHandler saves an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (interface{}, *errs.Error) {
var categoryModifyReq models.TransactionCategoryModifyRequest
err := c.ShouldBindJSON(&categoryModifyReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
category, err := a.categories.GetCategoryByCategoryId(uid, categoryModifyReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newCategory := &models.TransactionCategory{
CategoryId: category.CategoryId,
Uid: uid,
Name: categoryModifyReq.Name,
Icon: categoryModifyReq.Icon,
Color: categoryModifyReq.Color,
Comment: categoryModifyReq.Comment,
Hidden: categoryModifyReq.Hidden,
}
if newCategory.Name == category.Name &&
newCategory.Icon == category.Icon &&
newCategory.Color == category.Color &&
newCategory.Comment == category.Comment &&
newCategory.Hidden == category.Hidden {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.categories.ModifyCategory(newCategory)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] failed to update category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
newCategory.Type = category.Type
newCategory.ParentCategoryId = category.ParentCategoryId
newCategory.DisplayOrder = category.DisplayOrder
categoryResp := newCategory.ToTransactionCategoryInfoResponse()
return categoryResp, nil
}
// CategoryHideHandler hides an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.Context) (interface{}, *errs.Error) {
var categoryHideReq models.TransactionCategoryHideRequest
err := c.ShouldBindJSON(&categoryHideReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.categories.HideCategory(uid, []int64{categoryHideReq.Id}, categoryHideReq.Hidden)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryHideHandler] failed to hide category \"id:%d\" for user \"uid:%d\", because %s", categoryHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, categoryHideReq.Id)
return true, nil
}
// CategoryMoveHandler moves display order of existed transaction categories by request parameters for current user
func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.Context) (interface{}, *errs.Error) {
var categoryMoveReq models.TransactionCategoryMoveRequest
err := c.ShouldBindJSON(&categoryMoveReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
categories := make([]*models.TransactionCategory, len(categoryMoveReq.NewDisplayOrders))
for i := 0; i < len(categoryMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := categoryMoveReq.NewDisplayOrders[i]
category := &models.TransactionCategory{
Uid: uid,
CategoryId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
categories[i] = category
}
err = a.categories.ModifyCategoryDisplayOrders(uid, categories)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryMoveHandler] failed to move categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
return true, nil
}
// CategoryDeleteHandler deletes an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
var categoryDeleteReq models.TransactionCategoryDeleteRequest
err := c.ShouldBindJSON(&categoryDeleteReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.categories.DeleteCategory(uid, categoryDeleteReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] failed to delete category \"id:%d\" for user \"uid:%d\", because %s", categoryDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] user \"uid:%d\" has deleted category \"id:%d\"", uid, categoryDeleteReq.Id)
return true, nil
return categories, nil
}
func (a *TransactionCategoriesApi) createNewCategoryModel(uid int64, categoryCreateReq *models.TransactionCategoryCreateRequest, order int32) *models.TransactionCategory {
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"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/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// TransactionPicturesApi represents transaction pictures api
type TransactionPicturesApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
users *services.UserService
pictures *services.TransactionPictureService
}
// Initialize a transaction api singleton instance
var (
TransactionPictures = &TransactionPicturesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
container: duplicatechecker.Container,
},
users: services.Users,
pictures: services.TransactionPictures,
}
)
// TransactionPictureUploadHandler saves transaction picture by request parameters for current user
func (a *TransactionPicturesApi) TransactionPictureUploadHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrParameterInvalid
}
pictureFiles := form.File["picture"]
if len(pictureFiles) < 1 {
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] there is no transaction picture in request for user \"uid:%d\"", uid)
return nil, errs.ErrNoTransactionPicture
}
if pictureFiles[0].Size < 1 {
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the size of transaction picture in request is zero for user \"uid:%d\"", uid)
return nil, errs.ErrTransactionPictureIsEmpty
}
if pictureFiles[0].Size > int64(a.CurrentConfig().MaxTransactionPictureFileSize) {
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of transaction picture for user \"uid:%d\"", pictureFiles[0].Size, a.CurrentConfig().MaxTransactionPictureFileSize, uid)
return nil, errs.ErrExceedMaxTransactionPictureFileSize
}
fileExtension := utils.GetFileNameExtension(pictureFiles[0].Filename)
if utils.GetImageContentType(fileExtension) == "" {
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the file extension \"%s\" of transaction picture in request is not supported for user \"uid:%d\"", fileExtension, uid)
return nil, errs.ErrImageTypeNotSupported
}
pictureFile, err := pictureFiles[0].Open()
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get transaction picture file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
pictureInfo := a.createNewPictureInfoModel(uid, fileExtension, c.ClientIP())
clientSessionIds := form.Value["clientSessionId"]
clientSessionId := ""
if len(clientSessionIds) > 0 {
clientSessionId = clientSessionIds[0]
}
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && clientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId)
if found {
log.Infof(c, "[transaction_pictures.TransactionPictureUploadHandler] another transaction picture \"id:%s\" has been uploaded for user \"uid:%d\"", remark, uid)
pictureId, err := utils.StringToInt64(remark)
if err == nil {
pictureInfo, err = a.pictures.GetPictureInfoByPictureId(c, uid, pictureId)
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get existed transaction picture \"id:%d\" for user \"uid:%d\", because %s", pictureId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
return pictureInfoResp, nil
}
}
}
err = a.pictures.UploadPicture(c, pictureInfo, pictureFile)
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to update transaction picture for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId, utils.Int64ToString(pictureInfo.PictureId))
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
return pictureInfoResp, nil
}
// TransactionPictureGetHandler returns transaction picture data for current user
func (a *TransactionPicturesApi) TransactionPictureGetHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
fileName := c.Param("fileName")
fileExtension := utils.GetFileNameExtension(fileName)
contentType := utils.GetImageContentType(fileExtension)
if contentType == "" {
return nil, "", errs.ErrImageTypeNotSupported
}
fileBaseName := utils.GetFileNameWithoutExtension(fileName)
pictureId, err := utils.StringToInt64(fileBaseName)
if err != nil {
return nil, "", errs.ErrTransactionPictureIdInvalid
}
uid := c.GetCurrentUid()
pictureData, err := a.pictures.GetPictureByPictureId(c, uid, pictureId, fileExtension)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get transaction picture, because %s", err.Error())
}
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
return pictureData, contentType, nil
}
// TransactionPictureRemoveUnusedHandler removes unused transaction picture by request parameters for current user
func (a *TransactionPicturesApi) TransactionPictureRemoveUnusedHandler(c *core.WebContext) (any, *errs.Error) {
var pictureDeleteReq models.TransactionPictureUnusedDeleteRequest
err := c.ShouldBindJSON(&pictureDeleteReq)
if err != nil {
log.Warnf(c, "[transaction_pictures.TransactionPictureRemoveUnusedHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.pictures.RemoveUnusedTransactionPicture(c, uid, pictureDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureRemoveUnusedHandler] failed to remove unused transaction picture for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}
func (a *TransactionPicturesApi) createNewPictureInfoModel(uid int64, fileExtension string, clientIp string) *models.TransactionPictureInfo {
return &models.TransactionPictureInfo{
Uid: uid,
TransactionId: models.TransactionPictureNewPictureTransactionId,
PictureExtension: fileExtension,
CreatedIp: clientIp,
}
}
+37 -37
View File
@@ -23,12 +23,12 @@ var (
)
// TagListHandler returns transaction tag list of current user
func (a *TransactionTagsApi) TagListHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tags, err := a.tags.GetAllTagsByUid(uid)
tags, err := a.tags.GetAllTagsByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagListHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_tags.TagListHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -44,20 +44,20 @@ func (a *TransactionTagsApi) TagListHandler(c *core.Context) (interface{}, *errs
}
// TagGetHandler returns one specific transaction tag of current user
func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagGetHandler(c *core.WebContext) (any, *errs.Error) {
var tagGetReq models.TransactionTagGetRequest
err := c.ShouldBindQuery(&tagGetReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.TagGetHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tag, err := a.tags.GetTagByTagId(uid, tagGetReq.Id)
tag, err := a.tags.GetTagByTagId(c, uid, tagGetReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagGetHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagGetReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagGetHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -67,34 +67,34 @@ func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (interface{}, *errs.
}
// TagCreateHandler saves a new transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagCreateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Error) {
var tagCreateReq models.TransactionTagCreateRequest
err := c.ShouldBindJSON(&tagCreateReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.TagCreateHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
maxOrderId, err := a.tags.GetMaxDisplayOrder(uid)
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tag := a.createNewTagModel(uid, &tagCreateReq, maxOrderId+1)
err = a.tags.CreateTag(tag)
err = a.tags.CreateTag(c, tag)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagCreateHandler] failed to create tag \"id:%d\" for user \"uid:%d\", because %s", tag.TagId, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to create tag \"id:%d\" for user \"uid:%d\", because %s", tag.TagId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.TagCreateHandler] user \"uid:%d\" has created a new tag \"id:%d\" successfully", uid, tag.TagId)
log.Infof(c, "[transaction_tags.TagCreateHandler] user \"uid:%d\" has created a new tag \"id:%d\" successfully", uid, tag.TagId)
tagResp := tag.ToTransactionTagInfoResponse()
@@ -102,20 +102,20 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.Context) (interface{}, *er
}
// TagModifyHandler saves an existed transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Error) {
var tagModifyReq models.TransactionTagModifyRequest
err := c.ShouldBindJSON(&tagModifyReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.TagModifyHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tag, err := a.tags.GetTagByTagId(uid, tagModifyReq.Id)
tag, err := a.tags.GetTagByTagId(c, uid, tagModifyReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagModifyHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -129,14 +129,14 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (interface{}, *er
return nil, errs.ErrNothingWillBeUpdated
}
err = a.tags.ModifyTag(newTag)
err = a.tags.ModifyTag(c, newTag)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
log.Infof(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
tag.Name = newTag.Name
tagResp := tag.ToTransactionTagInfoResponse()
@@ -144,35 +144,35 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (interface{}, *er
return tagResp, nil
}
// TagHideHandler hides an transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagHideHandler(c *core.Context) (interface{}, *errs.Error) {
// TagHideHandler hides a transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagHideHandler(c *core.WebContext) (any, *errs.Error) {
var tagHideReq models.TransactionTagHideRequest
err := c.ShouldBindJSON(&tagHideReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.CategoryHideHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.tags.HideTag(uid, []int64{tagHideReq.Id}, tagHideReq.Hidden)
err = a.tags.HideTag(c, uid, []int64{tagHideReq.Id}, tagHideReq.Hidden)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.CategoryHideHandler] failed to hide tag \"id:%d\" for user \"uid:%d\", because %s", tagHideReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagHideHandler] failed to hide tag \"id:%d\" for user \"uid:%d\", because %s", tagHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, tagHideReq.Id)
log.Infof(c, "[transaction_tags.TagHideHandler] user \"uid:%d\" has hidden tag \"id:%d\"", uid, tagHideReq.Id)
return true, nil
}
// TagMoveHandler moves display order of existed transaction tags by request parameters for current user
func (a *TransactionTagsApi) TagMoveHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagMoveHandler(c *core.WebContext) (any, *errs.Error) {
var tagMoveReq models.TransactionTagMoveRequest
err := c.ShouldBindJSON(&tagMoveReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.CategoryMoveHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -190,36 +190,36 @@ func (a *TransactionTagsApi) TagMoveHandler(c *core.Context) (interface{}, *errs
tags[i] = tag
}
err = a.tags.ModifyTagDisplayOrders(uid, tags)
err = a.tags.ModifyTagDisplayOrders(c, uid, tags)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.CategoryMoveHandler] failed to move tags for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_tags.TagMoveHandler] failed to move tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
log.Infof(c, "[transaction_tags.TagMoveHandler] user \"uid:%d\" has moved tags", uid)
return true, nil
}
// TagDeleteHandler deletes an existed transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var tagDeleteReq models.TransactionTagDeleteRequest
err := c.ShouldBindJSON(&tagDeleteReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.TagDeleteHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.tags.DeleteTag(uid, tagDeleteReq.Id)
err = a.tags.DeleteTag(c, uid, tagDeleteReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagDeleteHandler] failed to delete tag \"id:%d\" for user \"uid:%d\", because %s", tagDeleteReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagDeleteHandler] failed to delete tag \"id:%d\" for user \"uid:%d\", because %s", tagDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.TagDeleteHandler] user \"uid:%d\" has deleted tag \"id:%d\"", uid, tagDeleteReq.Id)
log.Infof(c, "[transaction_tags.TagDeleteHandler] user \"uid:%d\" has deleted tag \"id:%d\"", uid, tagDeleteReq.Id)
return true, nil
}
+494
View File
@@ -0,0 +1,494 @@
package api
import (
"sort"
"strings"
"time"
"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/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const maximumTagsCountOfTemplate = 10
// TransactionTemplatesApi represents transaction template api
type TransactionTemplatesApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
templates *services.TransactionTemplateService
}
// Initialize a transaction template api singleton instance
var (
TransactionTemplates = &TransactionTemplatesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
container: duplicatechecker.Container,
},
templates: services.TransactionTemplates,
}
)
// TemplateListHandler returns transaction template list of current user
func (a *TransactionTemplatesApi) TemplateListHandler(c *core.WebContext) (any, *errs.Error) {
var templateListReq models.TransactionTemplateListRequest
err := c.ShouldBindQuery(&templateListReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateListHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if templateListReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateListReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
log.Warnf(c, "[transaction_templates.TemplateListHandler] template type invalid, type is %d", templateListReq.TemplateType)
return nil, errs.ErrTransactionTemplateTypeInvalid
}
if templateListReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
uid := c.GetCurrentUid()
templates, err := a.templates.GetAllTemplatesByUid(c, uid, templateListReq.TemplateType)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateListHandler] failed to get templates for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
templateResps := make(models.TransactionTemplateInfoResponseSlice, len(templates))
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
for i := 0; i < len(templates); i++ {
templateResps[i] = templates[i].ToTransactionTemplateInfoResponse(serverUtcOffset)
}
sort.Sort(templateResps)
return templateResps, nil
}
// TemplateGetHandler returns one specific transaction template of current user
func (a *TransactionTemplatesApi) TemplateGetHandler(c *core.WebContext) (any, *errs.Error) {
var templateGetReq models.TransactionTemplateGetRequest
err := c.ShouldBindQuery(&templateGetReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateGetReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateGetHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
}
// TemplateCreateHandler saves a new transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any, *errs.Error) {
var templateCreateReq models.TransactionTemplateCreateRequest
err := c.ShouldBindJSON(&templateCreateReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if templateCreateReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateCreateReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] template type invalid, type is %d", templateCreateReq.TemplateType)
return nil, errs.ErrTransactionTemplateTypeInvalid
}
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
if templateCreateReq.Type <= models.TRANSACTION_TYPE_MODIFY_BALANCE || templateCreateReq.Type > models.TRANSACTION_TYPE_TRANSFER {
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] transaction type invalid, type is %d", templateCreateReq.Type)
return nil, errs.ErrTransactionTypeInvalid
}
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if templateCreateReq.ScheduledFrequencyType == nil ||
templateCreateReq.ScheduledFrequency == nil ||
templateCreateReq.ScheduledTimezoneUtcOffset == nil {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
if *templateCreateReq.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateCreateReq.ScheduledFrequency != "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
} else if *templateCreateReq.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateCreateReq.ScheduledFrequency == "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
}
if len(templateCreateReq.TagIds) > maximumTagsCountOfTemplate {
return nil, errs.ErrTransactionTemplateHasTooManyTags
}
uid := c.GetCurrentUid()
maxOrderId, err := a.templates.GetMaxDisplayOrder(c, uid, templateCreateReq.TemplateType)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
template := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
if found {
log.Infof(c, "[transaction_templates.TemplateCreateHandler] another template \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
templateId, err := utils.StringToInt64(remark)
if err == nil {
template, err = a.templates.GetTemplateByTemplateId(c, uid, templateId)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to get existed template \"id:%d\" for user \"uid:%d\", because %s", templateId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
}
}
}
err = a.templates.CreateTemplate(c, template)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create template \"id:%d\" for user \"uid:%d\", because %s", template.TemplateId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateCreateHandler] user \"uid:%d\" has created a new template \"id:%d\" successfully", uid, template.TemplateId)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId, utils.Int64ToString(template.TemplateId))
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
}
// TemplateModifyHandler saves an existed transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any, *errs.Error) {
var templateModifyReq models.TransactionTemplateModifyRequest
err := c.ShouldBindJSON(&templateModifyReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if templateModifyReq.Type <= models.TRANSACTION_TYPE_MODIFY_BALANCE || templateModifyReq.Type > models.TRANSACTION_TYPE_TRANSFER {
log.Warnf(c, "[transaction_templates.TemplateModifyHandler] transaction type invalid, type is %d", templateModifyReq.Type)
return nil, errs.ErrTransactionTypeInvalid
}
uid := c.GetCurrentUid()
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateModifyReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if templateModifyReq.ScheduledFrequencyType == nil ||
templateModifyReq.ScheduledFrequency == nil ||
templateModifyReq.ScheduledTimezoneUtcOffset == nil {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
if *templateModifyReq.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateModifyReq.ScheduledFrequency != "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
} else if *templateModifyReq.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateModifyReq.ScheduledFrequency == "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
}
if len(templateModifyReq.TagIds) > maximumTagsCountOfTemplate {
return nil, errs.ErrTransactionTemplateHasTooManyTags
}
newTemplate := &models.TransactionTemplate{
TemplateId: template.TemplateId,
Uid: uid,
Name: templateModifyReq.Name,
Type: templateModifyReq.Type,
CategoryId: templateModifyReq.CategoryId,
AccountId: templateModifyReq.SourceAccountId,
TagIds: strings.Join(templateModifyReq.TagIds, ","),
Amount: templateModifyReq.SourceAmount,
RelatedAccountId: templateModifyReq.DestinationAccountId,
RelatedAccountAmount: templateModifyReq.DestinationAmount,
HideAmount: templateModifyReq.HideAmount,
Comment: templateModifyReq.Comment,
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
newTemplate.ScheduledFrequencyType = *templateModifyReq.ScheduledFrequencyType
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
}
if newTemplate.Name == template.Name &&
newTemplate.Type == template.Type &&
newTemplate.CategoryId == template.CategoryId &&
newTemplate.AccountId == template.AccountId &&
newTemplate.TagIds == template.TagIds &&
newTemplate.Amount == template.Amount &&
newTemplate.RelatedAccountId == template.RelatedAccountId &&
newTemplate.RelatedAccountAmount == template.RelatedAccountAmount &&
newTemplate.HideAmount == template.HideAmount &&
newTemplate.Comment == template.Comment {
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_NORMAL {
return nil, errs.ErrNothingWillBeUpdated
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
newTemplate.ScheduledAt == template.ScheduledAt &&
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
return nil, errs.ErrNothingWillBeUpdated
}
}
}
err = a.templates.ModifyTemplate(c, newTemplate)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to update template \"id:%d\" for user \"uid:%d\", because %s", templateModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateModifyHandler] user \"uid:%d\" has updated template \"id:%d\" successfully", uid, templateModifyReq.Id)
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
newTemplate.TemplateType = template.TemplateType
newTemplate.DisplayOrder = template.DisplayOrder
newTemplate.Hidden = template.Hidden
templateResp := newTemplate.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
}
// TemplateHideHandler hides a transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateHideHandler(c *core.WebContext) (any, *errs.Error) {
var templateHideReq models.TransactionTemplateHideRequest
err := c.ShouldBindJSON(&templateHideReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateHideReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateHideHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
err = a.templates.HideTemplate(c, uid, []int64{templateHideReq.Id}, templateHideReq.Hidden)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateHideHandler] failed to hide template \"id:%d\" for user \"uid:%d\", because %s", templateHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateHideHandler] user \"uid:%d\" has hidden template \"id:%d\"", uid, templateHideReq.Id)
return true, nil
}
// TemplateMoveHandler moves display order of existed transaction templates by request parameters for current user
func (a *TransactionTemplatesApi) TemplateMoveHandler(c *core.WebContext) (any, *errs.Error) {
var templateMoveReq models.TransactionTemplateMoveRequest
err := c.ShouldBindJSON(&templateMoveReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
if len(templateMoveReq.NewDisplayOrders) > 0 {
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateMoveReq.NewDisplayOrders[0].Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateMoveHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateMoveReq.NewDisplayOrders[0].Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
}
templates := make([]*models.TransactionTemplate, len(templateMoveReq.NewDisplayOrders))
for i := 0; i < len(templateMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := templateMoveReq.NewDisplayOrders[i]
template := &models.TransactionTemplate{
Uid: uid,
TemplateId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
templates[i] = template
}
err = a.templates.ModifyTemplateDisplayOrders(c, uid, templates)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateMoveHandler] failed to move templates for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateMoveHandler] user \"uid:%d\" has moved templates", uid)
return true, nil
}
// TemplateDeleteHandler deletes an existed transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var templateDeleteReq models.TransactionTemplateDeleteRequest
err := c.ShouldBindJSON(&templateDeleteReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateDeleteHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
err = a.templates.DeleteTemplate(c, uid, templateDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateDeleteHandler] failed to delete template \"id:%d\" for user \"uid:%d\", because %s", templateDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateDeleteHandler] user \"uid:%d\" has deleted template \"id:%d\"", uid, templateDeleteReq.Id)
return true, nil
}
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) *models.TransactionTemplate {
template := &models.TransactionTemplate{
Uid: uid,
TemplateType: templateCreateReq.TemplateType,
Name: templateCreateReq.Name,
Type: templateCreateReq.Type,
CategoryId: templateCreateReq.CategoryId,
AccountId: templateCreateReq.SourceAccountId,
TagIds: strings.Join(templateCreateReq.TagIds, ","),
Amount: templateCreateReq.SourceAmount,
RelatedAccountId: templateCreateReq.DestinationAccountId,
RelatedAccountAmount: templateCreateReq.DestinationAmount,
HideAmount: templateCreateReq.HideAmount,
Comment: templateCreateReq.Comment,
DisplayOrder: order,
}
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
template.ScheduledFrequencyType = *templateCreateReq.ScheduledFrequencyType
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
}
return template
}
func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
templateTimeZone := time.FixedZone("Template Timezone", int(scheduledTimezoneUtcOffset)*60)
transactionTime := time.Date(2020, 1, 1, 0, 0, 0, 0, templateTimeZone)
transactionTimeInUTC := transactionTime.In(time.UTC)
minutesElapsedOfDayInUtc := transactionTimeInUTC.Hour()*60 + transactionTimeInUTC.Minute()
return int16(minutesElapsedOfDayInUtc)
}
func (a *TransactionTemplatesApi) getOrderedFrequencyValues(frequencyValue string) string {
if frequencyValue == "" {
return ""
}
items := strings.Split(frequencyValue, ",")
values := make([]int, 0, len(items))
valueExistMap := make(map[int]bool)
for i := 0; i < len(items); i++ {
value, err := utils.StringToInt(items[i])
if err != nil {
continue
}
if _, exists := valueExistMap[value]; !exists {
values = append(values, value)
valueExistMap[value] = true
}
}
sort.Ints(values)
var sortedFrequencyValueBuilder strings.Builder
for i := 0; i < len(values); i++ {
if sortedFrequencyValueBuilder.Len() > 0 {
sortedFrequencyValueBuilder.WriteRune(',')
}
sortedFrequencyValueBuilder.WriteString(utils.IntToString(values[i]))
}
return sortedFrequencyValueBuilder.String()
}
+757 -253
View File
File diff suppressed because it is too large Load Diff
+52 -51
View File
@@ -32,9 +32,9 @@ var (
)
// TwoFactorStatusHandler returns 2fa status of current user
func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(uid)
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
if err == errs.ErrTwoFactorIsNotEnabled {
statusResp := &models.TwoFactorStatusResponse{
@@ -45,7 +45,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (in
}
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorStatusHandler] failed to get two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorStatusHandler] failed to get two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -58,12 +58,12 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (in
}
// TwoFactorEnableRequestHandler returns a new 2fa secret and qr code for current user to set 2fa and verify passcode next
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
enabled, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
enabled, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to check two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to check two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -71,27 +71,27 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
return nil, errs.ErrTwoFactorAlreadyEnabled
}
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(user)
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two factor secret, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor secret, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
img, err := key.Image(240, 240)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two factor qrcode, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor qrcode, because %s", err.Error())
return nil, errs.ErrOperationFailed
}
@@ -110,20 +110,20 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
}
// TwoFactorEnableConfirmHandler enables 2fa for current user
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.WebContext) (any, *errs.Error) {
var confirmReq models.TwoFactorEnableConfirmRequest
err := c.ShouldBindJSON(&confirmReq)
if err != nil {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
exists, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
exists, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to check two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to check two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -131,11 +131,11 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
return nil, errs.ErrTwoFactorAlreadyEnabled
}
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
@@ -147,46 +147,46 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
}
if !totp.Validate(confirmReq.Passcode, confirmReq.Secret) {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] passcode is invalid")
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] passcode is invalid")
return nil, errs.ErrPasscodeInvalid
}
recoveryCodes, err := a.twoFactorAuthorizations.GenerateTwoFactorRecoveryCodes()
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to generate two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to generate two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(uid, recoveryCodes, user.Salt)
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(c, uid, recoveryCodes, user.Salt)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.twoFactorAuthorizations.CreateTwoFactorSetting(twoFactorSetting)
err = a.twoFactorAuthorizations.CreateTwoFactorSetting(c, twoFactorSetting)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two factor setting for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" has enabled two factor authorization", uid)
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" has enabled two-factor authorization", uid)
now := time.Now().Unix()
err = a.tokens.DeleteTokensBeforeTime(uid, now)
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
if err == nil {
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
token, claims, err := a.tokens.CreateToken(user, c)
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
confirmResp := &models.TwoFactorEnableConfirmResponse{
RecoveryCodes: recoveryCodes,
@@ -195,9 +195,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
return confirmResp, nil
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
confirmResp := &models.TwoFactorEnableConfirmResponse{
Token: token,
@@ -208,21 +209,21 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
}
// TwoFactorDisableHandler disables 2fa for current user
func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.WebContext) (any, *errs.Error) {
var disableReq models.TwoFactorDisableRequest
err := c.ShouldBindJSON(&disableReq)
if err != nil {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorDisableHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
@@ -232,10 +233,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (i
return nil, errs.ErrUserPasswordWrong
}
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to check two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to check two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -243,41 +244,41 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (i
return nil, errs.ErrTwoFactorIsNotEnabled
}
err = a.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(uid)
err = a.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two factor recovery codes for user \"uid:%d\"", uid)
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two-factor recovery codes for user \"uid:%d\"", uid)
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.twoFactorAuthorizations.DeleteTwoFactorSetting(uid)
err = a.twoFactorAuthorizations.DeleteTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two factor setting for user \"uid:%d\"", uid)
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two-factor setting for user \"uid:%d\"", uid)
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] user \"uid:%d\" has disabled two factor authorization", uid)
log.Infof(c, "[twofactor_authorizations.TwoFactorDisableHandler] user \"uid:%d\" has disabled two-factor authorization", uid)
return true, nil
}
// TwoFactorRecoveryCodeRegenerateHandler returns new 2fa recovery codes and revokes old recovery codes for current user
func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *core.WebContext) (any, *errs.Error) {
var regenerateReq models.TwoFactorRegenerateRecoveryCodeRequest
err := c.ShouldBindJSON(&regenerateReq)
if err != nil {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
@@ -287,10 +288,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
return nil, errs.ErrUserPasswordWrong
}
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to check two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to check two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -301,14 +302,14 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
recoveryCodes, err := a.twoFactorAuthorizations.GenerateTwoFactorRecoveryCodes()
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to generate two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to generate two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(uid, recoveryCodes, user.Salt)
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(c, uid, recoveryCodes, user.Salt)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to create two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to create two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -316,7 +317,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
RecoveryCodes: recoveryCodes,
}
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] user \"uid:%d\" has regenerated two factor recovery codes", uid)
log.Infof(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] user \"uid:%d\" has regenerated two-factor recovery codes", uid)
return recoveryCodesResp, nil
}
+548 -49
View File
@@ -4,17 +4,24 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin/binding"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/locales"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
// UsersApi represents user api
type UsersApi struct {
ApiUsingConfig
ApiWithUserInfo
users *services.UserService
tokens *services.TokenService
accounts *services.AccountService
@@ -23,6 +30,17 @@ type UsersApi struct {
// Initialize a user api singleton instance
var (
Users = &UsersApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiWithUserInfo: ApiWithUserInfo{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
container: avatars.Container,
},
},
users: services.Users,
tokens: services.Tokens,
accounts: services.Accounts,
@@ -30,21 +48,21 @@ var (
)
// UserRegisterHandler saves a new user by request parameters
func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Error) {
if !settings.Container.Current.EnableUserRegister {
func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableUserRegister {
return nil, errs.ErrUserRegistrationNotAllowed
}
var userRegisterReq models.UserRegisterRequest
err := c.ShouldBindJSON(&userRegisterReq)
err := c.ShouldBindBodyWith(&userRegisterReq, binding.JSON)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[users.UserRegisterHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if userRegisterReq.DefaultCurrency == validators.ParentAccountCurrencyPlaceholder {
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] user default currency is invalid")
log.Warnf(c, "[users.UserRegisterHandler] user default currency is invalid")
return nil, errs.ErrUserDefaultCurrencyIsInvalid
}
@@ -57,73 +75,174 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Erro
Email: userRegisterReq.Email,
Nickname: userRegisterReq.Nickname,
Password: userRegisterReq.Password,
Language: userRegisterReq.Language,
DefaultCurrency: userRegisterReq.DefaultCurrency,
FirstDayOfWeek: userRegisterReq.FirstDayOfWeek,
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
}
err = a.users.CreateUser(user)
err = a.users.CreateUser(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
log.Errorf(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
log.Infof(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
authResp := &models.AuthResponse{
Need2FA: false,
User: user.ToUserBasicInfo(),
presetCategoriesSaved := false
if len(userRegisterReq.Categories) > 0 {
_, err = TransactionCategories.createBatchCategories(c, user.Uid, &userRegisterReq.TransactionCategoryCreateBatchRequest)
if err == nil {
presetCategoriesSaved = true
}
}
token, claims, err := a.tokens.CreateToken(user, c)
authResp := &models.RegisterResponse{
AuthResponse: models.AuthResponse{
Need2FA: false,
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterRegisterNotificationContent(user.Language, c.GetClientLocale()),
},
NeedVerifyEmail: a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableUserForceVerifyEmail,
PresetCategoriesSaved: presetCategoriesSaved,
}
if a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableSMTP {
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.Errorf(c, "[users.UserRegisterHandler] failed to create email verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
} else {
go func() {
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[users.UserRegisterHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
}
}()
}
}
if a.CurrentConfig().EnableUserForceVerifyEmail {
return authResp, nil
}
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[users.UserRegisterHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return authResp, nil
}
authResp.Token = token
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"uid:%d\" has logined, token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[users.UserRegisterHandler] user \"uid:%d\" has logined, token will be expired at %d", user.Uid, claims.ExpiresAt)
return authResp, nil
}
// UserProfileHandler returns user profile of current user
func (a *UsersApi) UserProfileHandler(c *core.Context) (interface{}, *errs.Error) {
// UserEmailVerifyHandler sets user email address verified
func (a *UsersApi) UserEmailVerifyHandler(c *core.WebContext) (any, *errs.Error) {
var userVerifyEmailReq models.UserVerifyEmailRequest
err := c.ShouldBindJSON(&userVerifyEmailReq)
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[users.UserEmailVerifyHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
userResp := user.ToUserProfileResponse()
if user.Disabled {
log.Warnf(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.EmailVerified {
log.Warnf(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
err = a.users.SetUserEmailVerified(c, user.Username)
if err != nil {
log.Errorf(c, "[users.UserEmailVerifyHandler] failed to update user \"uid:%d\" email address verified, because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.tokens.DeleteTokensByType(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
if err == nil {
log.Infof(c, "[users.UserEmailVerifyHandler] revoke old email verify tokens for user \"uid:%d\"", user.Uid)
} else {
log.Warnf(c, "[users.UserEmailVerifyHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
resp := &models.UserVerifyEmailResponse{}
if userVerifyEmailReq.RequestNewToken {
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.Warnf(c, "[users.UserEmailVerifyHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return resp, nil
}
resp.NewToken = token
resp.User = a.GetUserBasicInfo(user)
resp.NotificationContent = a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale())
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.Infof(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt)
}
return resp, nil
}
// UserProfileHandler returns user profile of current user
func (a *UsersApi) UserProfileHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserUpdateProfileHandler saves user profile by request parameters for current user
func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Error) {
var userUpdateReq models.UserProfileUpdateRequest
err := c.ShouldBindJSON(&userUpdateReq)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[users.UserUpdateProfileHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
@@ -161,32 +280,28 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
anythingUpdate = true
}
if userUpdateReq.DefaultCurrency != "" && userUpdateReq.DefaultCurrency != user.DefaultCurrency {
user.DefaultCurrency = userUpdateReq.DefaultCurrency
userNew.DefaultCurrency = userUpdateReq.DefaultCurrency
anythingUpdate = true
}
if userUpdateReq.DefaultAccountId > 0 && userUpdateReq.DefaultAccountId != user.DefaultAccountId {
accounts, err := a.accounts.GetAccountsByAccountIds(uid, []int64{userUpdateReq.DefaultAccountId})
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, []int64{userUpdateReq.DefaultAccountId})
if err != nil || len(accounts) < 1 {
if err != nil || len(accountMap) < 1 {
return nil, errs.Or(err, errs.ErrUserDefaultAccountIsInvalid)
}
if _, exists := accountMap[userUpdateReq.DefaultAccountId]; !exists {
log.Warnf(c, "[users.UserUpdateProfileHandler] account \"id:%d\" does not exist for user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
return nil, errs.ErrUserDefaultAccountIsInvalid
}
if accountMap[userUpdateReq.DefaultAccountId].Hidden {
log.Warnf(c, "[users.UserUpdateProfileHandler] account \"id:%d\" is hidden of user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
return nil, errs.ErrUserDefaultAccountIsHidden
}
user.DefaultAccountId = userUpdateReq.DefaultAccountId
userNew.DefaultAccountId = userUpdateReq.DefaultAccountId
anythingUpdate = true
}
if userUpdateReq.FirstDayOfWeek != nil && *userUpdateReq.FirstDayOfWeek != user.FirstDayOfWeek {
user.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
userNew.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
anythingUpdate = true
} else {
userNew.FirstDayOfWeek = models.WEEKDAY_INVALID
}
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
@@ -195,47 +310,431 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
userNew.TransactionEditScope = models.TRANSACTION_EDIT_SCOPE_INVALID
}
modifyUserLanguage := false
if userUpdateReq.Language != user.Language {
user.Language = userUpdateReq.Language
userNew.Language = userUpdateReq.Language
modifyUserLanguage = true
anythingUpdate = true
}
if userUpdateReq.DefaultCurrency != "" && userUpdateReq.DefaultCurrency != user.DefaultCurrency {
user.DefaultCurrency = userUpdateReq.DefaultCurrency
userNew.DefaultCurrency = userUpdateReq.DefaultCurrency
anythingUpdate = true
}
if userUpdateReq.FirstDayOfWeek != nil && *userUpdateReq.FirstDayOfWeek != user.FirstDayOfWeek {
user.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
userNew.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
anythingUpdate = true
} else {
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
}
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
user.LongDateFormat = *userUpdateReq.LongDateFormat
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
anythingUpdate = true
} else {
userNew.LongDateFormat = core.LONG_DATE_FORMAT_INVALID
}
if userUpdateReq.ShortDateFormat != nil && *userUpdateReq.ShortDateFormat != user.ShortDateFormat {
user.ShortDateFormat = *userUpdateReq.ShortDateFormat
userNew.ShortDateFormat = *userUpdateReq.ShortDateFormat
anythingUpdate = true
} else {
userNew.ShortDateFormat = core.SHORT_DATE_FORMAT_INVALID
}
if userUpdateReq.LongTimeFormat != nil && *userUpdateReq.LongTimeFormat != user.LongTimeFormat {
user.LongTimeFormat = *userUpdateReq.LongTimeFormat
userNew.LongTimeFormat = *userUpdateReq.LongTimeFormat
anythingUpdate = true
} else {
userNew.LongTimeFormat = core.LONG_TIME_FORMAT_INVALID
}
if userUpdateReq.ShortTimeFormat != nil && *userUpdateReq.ShortTimeFormat != user.ShortTimeFormat {
user.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
userNew.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
anythingUpdate = true
} else {
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
}
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
anythingUpdate = true
} else {
userNew.DecimalSeparator = core.DECIMAL_SEPARATOR_INVALID
}
if userUpdateReq.DigitGroupingSymbol != nil && *userUpdateReq.DigitGroupingSymbol != user.DigitGroupingSymbol {
user.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
userNew.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
anythingUpdate = true
} else {
userNew.DigitGroupingSymbol = core.DIGIT_GROUPING_SYMBOL_INVALID
}
if userUpdateReq.DigitGrouping != nil && *userUpdateReq.DigitGrouping != user.DigitGrouping {
user.DigitGrouping = *userUpdateReq.DigitGrouping
userNew.DigitGrouping = *userUpdateReq.DigitGrouping
anythingUpdate = true
} else {
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
}
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
anythingUpdate = true
} else {
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
}
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
user.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
userNew.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
anythingUpdate = true
} else {
userNew.ExpenseAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
}
if userUpdateReq.IncomeAmountColor != nil && *userUpdateReq.IncomeAmountColor != user.IncomeAmountColor {
user.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
userNew.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
anythingUpdate = true
} else {
userNew.IncomeAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
}
if modifyUserLanguage || userNew.DecimalSeparator != core.DECIMAL_SEPARATOR_INVALID || userNew.DigitGroupingSymbol != core.DIGIT_GROUPING_SYMBOL_INVALID {
decimalSeparator := userNew.DecimalSeparator
digitGroupingSymbol := userNew.DigitGroupingSymbol
if userNew.DecimalSeparator == core.DECIMAL_SEPARATOR_INVALID {
decimalSeparator = user.DecimalSeparator
}
if userNew.DigitGroupingSymbol == core.DIGIT_GROUPING_SYMBOL_INVALID {
digitGroupingSymbol = user.DigitGroupingSymbol
}
locale := user.Language
if modifyUserLanguage {
locale = userNew.Language
}
if locale == "" {
locale = c.GetClientLocale()
}
if locales.IsDecimalSeparatorEqualsDigitGroupingSymbol(decimalSeparator, digitGroupingSymbol, locale) {
return nil, errs.ErrDecimalSeparatorAndDigitGroupingSymbolCannotBeEqual
}
}
if !anythingUpdate {
return nil, errs.ErrNothingWillBeUpdated
}
keyProfileUpdated, err := a.users.UpdateUser(userNew)
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" has updated successfully", user.Uid)
if emailSetToUnverified {
user.EmailVerified = false
}
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" has updated successfully", user.Uid)
resp := &models.UserProfileUpdateResponse{
User: user.ToUserBasicInfo(),
User: a.GetUserBasicInfo(user),
}
if emailSetToUnverified && a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableSMTP {
err = a.tokens.DeleteTokensByType(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
if err != nil {
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
} else {
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to create email verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
} else {
go func() {
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[users.UserUpdateProfileHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
}
}()
}
}
}
if keyProfileUpdated {
now := time.Now().Unix()
err = a.tokens.DeleteTokensBeforeTime(uid, now)
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
if err == nil {
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
log.Infof(c, "[users.UserUpdateProfileHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[users.UserUpdateProfileHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
token, claims, err := a.tokens.CreateToken(user, c)
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[users.UserUpdateProfileHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return resp, nil
}
resp.NewToken = token
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
return resp, nil
}
return resp, nil
}
// UserUpdateAvatarHandler saves user avatar by request parameters for current user
func (a *UsersApi) UserUpdateAvatarHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get multi-part form data for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrParameterInvalid
}
avatarFiles := form.File["avatar"]
if len(avatarFiles) < 1 {
log.Warnf(c, "[users.UserUpdateAvatarHandler] there is no user avatar in request for user \"uid:%d\"", user.Uid)
return nil, errs.ErrNoUserAvatar
}
if avatarFiles[0].Size < 1 {
log.Warnf(c, "[users.UserUpdateAvatarHandler] the size of user avatar in request is zero for user \"uid:%d\"", user.Uid)
return nil, errs.ErrUserAvatarIsEmpty
}
if avatarFiles[0].Size > int64(a.CurrentConfig().MaxAvatarFileSize) {
log.Warnf(c, "[users.UserUpdateAvatarHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of user avatar for user \"uid:%d\"", avatarFiles[0].Size, a.CurrentConfig().MaxAvatarFileSize, uid)
return nil, errs.ErrExceedMaxUserAvatarFileSize
}
fileExtension := utils.GetFileNameExtension(avatarFiles[0].Filename)
if utils.GetImageContentType(fileExtension) == "" {
log.Warnf(c, "[users.UserUpdateAvatarHandler] the file extension \"%s\" of user avatar in request is not supported for user \"uid:%d\"", fileExtension, user.Uid)
return nil, errs.ErrImageTypeNotSupported
}
avatarFile, err := avatarFiles[0].Open()
if err != nil {
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get avatar file from request for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrOperationFailed
}
err = a.users.UpdateUserAvatar(c, user.Uid, avatarFile, fileExtension, user.CustomAvatarType)
if err != nil {
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to update avatar for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
user.CustomAvatarType = fileExtension
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserRemoveAvatarHandler removes user avatar by request parameters for current user
func (a *UsersApi) UserRemoveAvatarHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserRemoveAvatarHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.CustomAvatarType == "" {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.users.RemoveUserAvatar(c, user.Uid, user.CustomAvatarType)
if err != nil {
log.Errorf(c, "[users.UserRemoveAvatarHandler] failed to remove avatar for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
user.CustomAvatarType = ""
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserSendVerifyEmailByUnloginUserHandler sends unlogin user verify email
func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableUserVerifyEmail {
return nil, errs.ErrEmailValidationNotAllowed
}
var userResendVerifyEmailReq models.UserResendVerifyEmailRequest
err := c.ShouldBindJSON(&userResendVerifyEmailReq)
user, err := a.users.GetUserByEmail(c, userResendVerifyEmailReq.Email)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(userResendVerifyEmailReq.Password, user) {
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] request password not equals to the user password")
return nil, errs.ErrUserPasswordWrong
}
if user.Disabled {
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.EmailVerified {
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.Errorf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
go func() {
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
return true, nil
}
// UserSendVerifyEmailByLoginedUserHandler sends logined user verify email
func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableUserVerifyEmail {
return nil, errs.ErrEmailValidationNotAllowed
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.EmailVerified {
log.Warnf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.Errorf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
go func() {
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
return true, nil
}
// UserGetAvatarHandler returns user avatar data for current user
func (a *UsersApi) UserGetAvatarHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
fileName := c.Param("fileName")
fileExtension := utils.GetFileNameExtension(fileName)
contentType := utils.GetImageContentType(fileExtension)
if contentType == "" {
return nil, "", errs.ErrImageTypeNotSupported
}
uid := c.GetCurrentUid()
fileBaseName := utils.GetFileNameWithoutExtension(fileName)
if utils.Int64ToString(uid) != fileBaseName {
log.Warnf(c, "[users.UserGetAvatarHandler] cannot get other user avatar \"uid:%s\" for user \"uid:%d\"", fileBaseName, uid)
return nil, "", errs.ErrUserIdInvalid
}
avatarData, err := a.users.GetUserAvatar(c, uid, fileExtension)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserGetAvatarHandler] failed to get user avatar, because %s", err.Error())
}
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
return avatarData, contentType, nil
}
func (a *UsersApi) getUserProfileResponse(user *models.User) *models.UserProfileResponse {
return user.ToUserProfileResponse(a.GetUserBasicInfo(user))
}
+8
View File
@@ -0,0 +1,8 @@
package avatars
import "github.com/mayswind/ezbookkeeping/pkg/models"
// AvatarProvider is user avatar provider interface
type AvatarProvider interface {
GetAvatarUrl(user *models.User) string
}
+39
View File
@@ -0,0 +1,39 @@
package avatars
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// AvatarProviderContainer contains the current user avatar provider
type AvatarProviderContainer struct {
Current AvatarProvider
}
// Initialize a user avatar provider container singleton instance
var (
Container = &AvatarProviderContainer{}
)
// InitializeAvatarProvider initializes the current user avatar provider according to the config
func InitializeAvatarProvider(config *settings.Config) error {
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
Container.Current = NewInternalStorageAvatarProvider(config)
return nil
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
Container.Current = NewGravatarAvatarProvider()
return nil
} else if config.AvatarProvider == "" {
Container.Current = NewNullAvatarProvider()
return nil
}
return errs.ErrInvalidAvatarProvider
}
// GetAvatarUrl returns the avatar url by the current user avatar provider
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
return p.Current.GetAvatarUrl(user)
}
+31
View File
@@ -0,0 +1,31 @@
package avatars
import (
"fmt"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// Reference: https://en.gravatar.com/site/implement/hash/
const gravatarUrlFormat = "https://www.gravatar.com/avatar/%s"
// GravatarAvatarProvider represents the gravatar avatar provider
type GravatarAvatarProvider struct {
}
// NewGravatarAvatarProvider returns a new gravatar avatar provider
func NewGravatarAvatarProvider() *GravatarAvatarProvider {
return &GravatarAvatarProvider{}
}
// GetAvatarUrl returns the gravatar url
func (p *GravatarAvatarProvider) GetAvatarUrl(user *models.User) string {
email := user.Email
email = strings.TrimSpace(email)
email = strings.ToLower(email)
emailMd5 := utils.MD5EncodeToString([]byte(email))
return fmt.Sprintf(gravatarUrlFormat, emailMd5)
}
+20
View File
@@ -0,0 +1,20 @@
package avatars
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
func TestGravatarAvatarProvider_GetGravatarUrl(t *testing.T) {
avatarProvider := NewGravatarAvatarProvider()
expectedValue := "https://www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346"
actualValue := avatarProvider.GetAvatarUrl(&models.User{
Email: "MyEmailAddress@example.com",
})
assert.Equal(t, expectedValue, actualValue)
}
+31
View File
@@ -0,0 +1,31 @@
package avatars
import (
"fmt"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const internalAvatarUrlFormat = "%savatar/%d.%s"
// InternalStorageAvatarProvider represents the internal storage avatar provider
type InternalStorageAvatarProvider struct {
webRootUrl string
}
// NewInternalStorageAvatarProvider returns a new internal storage avatar provider
func NewInternalStorageAvatarProvider(config *settings.Config) *InternalStorageAvatarProvider {
return &InternalStorageAvatarProvider{
webRootUrl: config.RootUrl,
}
}
// GetAvatarUrl returns the built-in avatar url
func (p *InternalStorageAvatarProvider) GetAvatarUrl(user *models.User) string {
if user.CustomAvatarType == "" {
return ""
}
return fmt.Sprintf(internalAvatarUrlFormat, p.webRootUrl, user.Uid, user.CustomAvatarType)
}
@@ -0,0 +1,38 @@
package avatars
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
func TestInternalStorageAvatarProvider_GetAvatarUrl(t *testing.T) {
avatarProvider := NewInternalStorageAvatarProvider(&settings.Config{
RootUrl: "https://foo.bar/",
})
expectedValue := "https://foo.bar/avatar/1234567890.jpg"
actualValue := avatarProvider.GetAvatarUrl(&models.User{
Uid: 1234567890,
CustomAvatarType: "jpg",
})
assert.Equal(t, expectedValue, actualValue)
}
func TestInternalStorageAvatarProvider_GetAvatarUrl_EmptyCustomAvatarType(t *testing.T) {
avatarProvider := NewInternalStorageAvatarProvider(&settings.Config{
RootUrl: "https://foo.bar/",
})
expectedValue := ""
actualValue := avatarProvider.GetAvatarUrl(&models.User{
Uid: 1234567890,
CustomAvatarType: "",
})
assert.Equal(t, expectedValue, actualValue)
}
+19
View File
@@ -0,0 +1,19 @@
package avatars
import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// NullAvatarProvider represents the null avatar provider
type NullAvatarProvider struct {
}
// NewNullAvatarProvider returns a new null avatar provider
func NewNullAvatarProvider() *NullAvatarProvider {
return &NullAvatarProvider{}
}
// GetAvatarUrl returns an empty url
func (p *NullAvatarProvider) GetAvatarUrl(user *models.User) string {
return ""
}
+20
View File
@@ -0,0 +1,20 @@
package avatars
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
func TestNullAvatarProvider_GetGravatarUrl(t *testing.T) {
avatarProvider := NewNullAvatarProvider()
expectedValue := ""
actualValue := avatarProvider.GetAvatarUrl(&models.User{
Email: "MyEmailAddress@example.com",
})
assert.Equal(t, expectedValue, actualValue)
}
+13
View File
@@ -0,0 +1,13 @@
package cli
import "github.com/mayswind/ezbookkeeping/pkg/settings"
// CliUsingConfig represents a cli that need to use config
type CliUsingConfig struct {
container *settings.ConfigContainer
}
// CurrentConfig returns the current config
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
return l.container.Current
}
+482 -127
View File
@@ -1,15 +1,17 @@
package cli
import (
"strings"
"time"
"github.com/urfave/cli/v2"
"github.com/mayswind/ezbookkeeping/pkg/converters"
"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/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
@@ -18,59 +20,63 @@ const pageCountForDataExport = 1000
// UserDataCli represents user data cli
type UserDataCli struct {
ezBookKeepingCsvExporter *converters.EzBookKeepingCSVFileExporter
accounts *services.AccountService
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
users *services.UserService
twoFactorAuthorizations *services.TwoFactorAuthorizationService
tokens *services.TokenService
CliUsingConfig
accounts *services.AccountService
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
users *services.UserService
twoFactorAuthorizations *services.TwoFactorAuthorizationService
tokens *services.TokenService
forgetPasswords *services.ForgetPasswordService
}
// Initialize an user data cli singleton instance
// Initialize a user data cli singleton instance
var (
UserData = &UserDataCli{
ezBookKeepingCsvExporter: &converters.EzBookKeepingCSVFileExporter{},
accounts: services.Accounts,
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
users: services.Users,
twoFactorAuthorizations: services.TwoFactorAuthorizations,
tokens: services.Tokens,
CliUsingConfig: CliUsingConfig{
container: settings.Container,
},
accounts: services.Accounts,
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
users: services.Users,
twoFactorAuthorizations: services.TwoFactorAuthorizations,
tokens: services.Tokens,
forgetPasswords: services.ForgetPasswords,
}
)
// AddNewUser adds a new user according to specified info
func (l *UserDataCli) AddNewUser(c *cli.Context, username string, email string, nickname string, password string, defaultCurrency string) (*models.User, error) {
func (l *UserDataCli) AddNewUser(c *core.CliContext, username string, email string, nickname string, password string, defaultCurrency string) (*models.User, error) {
if username == "" {
log.BootErrorf("[user_data.AddNewUser] user name is empty")
log.CliErrorf(c, "[user_data.AddNewUser] user name is empty")
return nil, errs.ErrUsernameIsEmpty
}
if email == "" {
log.BootErrorf("[user_data.AddNewUser] user email is empty")
log.CliErrorf(c, "[user_data.AddNewUser] user email is empty")
return nil, errs.ErrEmailIsEmpty
}
if nickname == "" {
log.BootErrorf("[user_data.AddNewUser] user nickname is empty")
log.CliErrorf(c, "[user_data.AddNewUser] user nickname is empty")
return nil, errs.ErrNicknameIsEmpty
}
if password == "" {
log.BootErrorf("[user_data.AddNewUser] user password is empty")
log.CliErrorf(c, "[user_data.AddNewUser] user password is empty")
return nil, errs.ErrPasswordIsEmpty
}
if defaultCurrency == "" {
log.BootErrorf("[user_data.AddNewUser] user default currency is empty")
log.CliErrorf(c, "[user_data.AddNewUser] user default currency is empty")
return nil, errs.ErrUserDefaultCurrencyIsEmpty
}
if _, ok := validators.AllCurrencyNames[defaultCurrency]; !ok {
log.BootErrorf("[user_data.AddNewUser] user default currency is invalid")
log.CliErrorf(c, "[user_data.AddNewUser] user default currency is invalid")
return nil, errs.ErrUserDefaultCurrencyIsInvalid
}
@@ -80,33 +86,33 @@ func (l *UserDataCli) AddNewUser(c *cli.Context, username string, email string,
Nickname: nickname,
Password: password,
DefaultCurrency: defaultCurrency,
FirstDayOfWeek: models.WEEKDAY_SUNDAY,
FirstDayOfWeek: core.WEEKDAY_SUNDAY,
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
}
err := l.users.CreateUser(user)
err := l.users.CreateUser(c, user)
if err != nil {
log.BootErrorf("[user_data.AddNewUser] failed to create user \"%s\", because %s", user.Username, err.Error())
log.CliErrorf(c, "[user_data.AddNewUser] failed to create user \"%s\", because %s", user.Username, err.Error())
return nil, err
}
log.BootInfof("[user_data.AddNewUser] user \"%s\" has add successfully, uid is %d", user.Username, user.Uid)
log.CliInfof(c, "[user_data.AddNewUser] user \"%s\" has add successfully, uid is %d", user.Username, user.Uid)
return user, nil
}
// GetUserByUsername returns user by user name
func (l *UserDataCli) GetUserByUsername(c *cli.Context, username string) (*models.User, error) {
func (l *UserDataCli) GetUserByUsername(c *core.CliContext, username string) (*models.User, error) {
if username == "" {
log.BootErrorf("[user_data.GetUserByUsername] user name is empty")
log.CliErrorf(c, "[user_data.GetUserByUsername] user name is empty")
return nil, errs.ErrUsernameIsEmpty
}
user, err := l.users.GetUserByUsername(username)
user, err := l.users.GetUserByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.GetUserByUsername] failed to get user by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.GetUserByUsername] failed to get user by user name \"%s\", because %s", username, err.Error())
return nil, err
}
@@ -114,21 +120,21 @@ func (l *UserDataCli) GetUserByUsername(c *cli.Context, username string) (*model
}
// ModifyUserPassword modifies user password
func (l *UserDataCli) ModifyUserPassword(c *cli.Context, username string, password string) error {
func (l *UserDataCli) ModifyUserPassword(c *core.CliContext, username string, password string) error {
if username == "" {
log.BootErrorf("[user_data.ModifyUserPassword] user name is empty")
log.CliErrorf(c, "[user_data.ModifyUserPassword] user name is empty")
return errs.ErrUsernameIsEmpty
}
if password == "" {
log.BootErrorf("[user_data.ModifyUserPassword] user password is empty")
log.CliErrorf(c, "[user_data.ModifyUserPassword] user password is empty")
return errs.ErrPasswordIsEmpty
}
user, err := l.users.GetUserByUsername(username)
user, err := l.users.GetUserByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.ModifyUserPassword] failed to get user by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.ModifyUserPassword] failed to get user by user name \"%s\", because %s", username, err.Error())
return err
}
@@ -142,36 +148,180 @@ func (l *UserDataCli) ModifyUserPassword(c *cli.Context, username string, passwo
Password: password,
}
_, err = l.users.UpdateUser(userNew)
_, _, err = l.users.UpdateUser(c, userNew, false)
if err != nil {
log.BootErrorf("[user_data.ModifyUserPassword] failed to update user \"%s\" password, because %s", user.Username, err.Error())
log.CliErrorf(c, "[user_data.ModifyUserPassword] failed to update user \"%s\" password, because %s", user.Username, err.Error())
return err
}
now := time.Now().Unix()
err = l.tokens.DeleteTokensBeforeTime(user.Uid, now)
err = l.tokens.DeleteTokensBeforeTime(c, user.Uid, now)
if err == nil {
log.BootInfof("[user_data.ModifyUserPassword] revoke old tokens before unix time \"%d\" for user \"%s\"", now, user.Username)
log.CliInfof(c, "[user_data.ModifyUserPassword] revoke old tokens before unix time \"%d\" for user \"%s\"", now, user.Username)
} else {
log.BootWarnf("[user_data.ModifyUserPassword] failed to revoke old tokens for user \"%s\", because %s", user.Username, err.Error())
log.CliWarnf(c, "[user_data.ModifyUserPassword] failed to revoke old tokens for user \"%s\", because %s", user.Username, err.Error())
}
return nil
}
// SendPasswordResetMail sends an email with password reset link
func (l *UserDataCli) SendPasswordResetMail(c *core.CliContext, username string) error {
if username == "" {
log.CliErrorf(c, "[user_data.SendPasswordResetMail] user name is empty")
return errs.ErrUsernameIsEmpty
}
user, err := l.users.GetUserByUsername(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.SendPasswordResetMail] failed to get user by user name \"%s\", because %s", username, err.Error())
return err
}
if l.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.CliWarnf(c, "[user_data.SendPasswordResetMail] user \"uid:%d\" has not verified email", user.Uid)
return errs.ErrEmailIsNotVerified
}
token, _, err := l.tokens.CreatePasswordResetTokenWithoutUserAgent(c, user)
if err != nil {
log.CliErrorf(c, "[user_data.SendPasswordResetMail] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return err
}
err = l.forgetPasswords.SendPasswordResetEmail(c, user, token, "")
if err != nil {
log.CliWarnf(c, "[user_data.SendPasswordResetMail] cannot send email to \"%s\", because %s", user.Email, err.Error())
return err
}
return nil
}
// EnableUser sets user enabled according to the specified user name
func (l *UserDataCli) EnableUser(c *core.CliContext, username string) error {
if username == "" {
log.CliErrorf(c, "[user_data.EnableUser] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.EnableUser(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.EnableUser] failed to set user enabled by user name \"%s\", because %s", username, err.Error())
return err
}
return nil
}
// DisableUser sets user disabled according to the specified user name
func (l *UserDataCli) DisableUser(c *core.CliContext, username string) error {
if username == "" {
log.CliErrorf(c, "[user_data.DisableUser] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.DisableUser(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.DisableUser] failed to set user disabled 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 {
return errs.ErrEmailValidationNotAllowed
}
if username == "" {
log.CliErrorf(c, "[user_data.ResendVerifyEmail] user name is empty")
return errs.ErrUsernameIsEmpty
}
user, err := l.users.GetUserByUsername(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.ResendVerifyEmail] failed to get user by user name \"%s\", because %s", username, err.Error())
return err
}
if user.EmailVerified {
log.CliWarnf(c, "[user_data.ResendVerifyEmail] user \"uid:%d\" email has been verified", user.Uid)
return errs.ErrEmailIsVerified
}
token, _, err := l.tokens.CreateEmailVerifyTokenWithoutUserAgent(c, user)
if err != nil {
log.CliErrorf(c, "[user_data.ResendVerifyEmail] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return errs.ErrTokenGenerating
}
err = l.users.SendVerifyEmail(user, token, "")
if err != nil {
log.CliErrorf(c, "[user_data.ResendVerifyEmail] cannot send email to \"%s\", because %s", user.Email, err.Error())
return err
}
return nil
}
// SetUserEmailVerified sets user email address verified
func (l *UserDataCli) SetUserEmailVerified(c *core.CliContext, username string) error {
if username == "" {
log.CliErrorf(c, "[user_data.SetUserEmailVerified] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.SetUserEmailVerified(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.SetUserEmailVerified] failed to set user email address verified by user name \"%s\", because %s", username, err.Error())
return err
}
return nil
}
// SetUserEmailUnverified sets user email address unverified
func (l *UserDataCli) SetUserEmailUnverified(c *core.CliContext, username string) error {
if username == "" {
log.CliErrorf(c, "[user_data.SetUserEmailUnverified] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.SetUserEmailUnverified(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.SetUserEmailUnverified] failed to set user email address unverified by user name \"%s\", because %s", username, err.Error())
return err
}
return nil
}
// DeleteUser deletes user according to the specified user name
func (l *UserDataCli) DeleteUser(c *cli.Context, username string) error {
func (l *UserDataCli) DeleteUser(c *core.CliContext, username string) error {
if username == "" {
log.BootErrorf("[user_data.DeleteUser] user name is empty")
log.CliErrorf(c, "[user_data.DeleteUser] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.DeleteUser(username)
err := l.users.DeleteUser(c, username)
if err != nil {
log.BootErrorf("[user_data.DeleteUser] failed to delete user by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.DeleteUser] failed to delete user by user name \"%s\", because %s", username, err.Error())
return err
}
@@ -179,23 +329,23 @@ func (l *UserDataCli) DeleteUser(c *cli.Context, username string) error {
}
// ListUserTokens returns all tokens of the specified user
func (l *UserDataCli) ListUserTokens(c *cli.Context, username string) ([]*models.TokenRecord, error) {
func (l *UserDataCli) ListUserTokens(c *core.CliContext, username string) ([]*models.TokenRecord, error) {
if username == "" {
log.BootErrorf("[user_data.ListUserTokens] user name is empty")
log.CliErrorf(c, "[user_data.ListUserTokens] user name is empty")
return nil, errs.ErrUsernameIsEmpty
}
uid, err := l.getUserIdByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.ListUserTokens] error occurs when getting user id by user name")
log.CliErrorf(c, "[user_data.ListUserTokens] error occurs when getting user id by user name")
return nil, err
}
tokens, err := l.tokens.GetAllUnexpiredNormalTokensByUid(uid)
tokens, err := l.tokens.GetAllUnexpiredNormalTokensByUid(c, uid)
if err != nil {
log.BootErrorf("[user_data.ListUserTokens] failed to get tokens of user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.ListUserTokens] failed to get tokens of user \"%s\", because %s", username, err.Error())
return nil, err
}
@@ -203,24 +353,24 @@ func (l *UserDataCli) ListUserTokens(c *cli.Context, username string) ([]*models
}
// ClearUserTokens clears all tokens of the specified user
func (l *UserDataCli) ClearUserTokens(c *cli.Context, username string) error {
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
if username == "" {
log.BootErrorf("[user_data.ClearUserTokens] user name is empty")
log.CliErrorf(c, "[user_data.ClearUserTokens] user name is empty")
return errs.ErrUsernameIsEmpty
}
uid, err := l.getUserIdByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.ClearUserTokens] error occurs when getting user id by user name")
log.CliErrorf(c, "[user_data.ClearUserTokens] error occurs when getting user id by user name")
return err
}
now := time.Now().Unix()
err = l.tokens.DeleteTokensBeforeTime(uid, now)
err = l.tokens.DeleteTokensBeforeTime(c, uid, now)
if err != nil {
log.BootErrorf("[user_data.ClearUserTokens] failed to delete tokens of user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.ClearUserTokens] failed to delete tokens of user \"%s\", because %s", username, err.Error())
return err
}
@@ -228,23 +378,23 @@ func (l *UserDataCli) ClearUserTokens(c *cli.Context, username string) error {
}
// DisableUserTwoFactorAuthorization disables 2fa for the specified user
func (l *UserDataCli) DisableUserTwoFactorAuthorization(c *cli.Context, username string) error {
func (l *UserDataCli) DisableUserTwoFactorAuthorization(c *core.CliContext, username string) error {
if username == "" {
log.BootErrorf("[user_data.DisableUserTwoFactorAuthorization] user name is empty")
log.CliErrorf(c, "[user_data.DisableUserTwoFactorAuthorization] user name is empty")
return errs.ErrUsernameIsEmpty
}
uid, err := l.getUserIdByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.DisableUserTwoFactorAuthorization] error occurs when getting user id by user name")
log.CliErrorf(c, "[user_data.DisableUserTwoFactorAuthorization] error occurs when getting user id by user name")
return err
}
enableTwoFactor, err := l.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
enableTwoFactor, err := l.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.BootErrorf("[user_data.DisableUserTwoFactorAuthorization] failed to check two factor setting, because %s", err.Error())
log.CliErrorf(c, "[user_data.DisableUserTwoFactorAuthorization] failed to check two-factor setting, because %s", err.Error())
return err
}
@@ -252,17 +402,17 @@ func (l *UserDataCli) DisableUserTwoFactorAuthorization(c *cli.Context, username
return errs.ErrTwoFactorIsNotEnabled
}
err = l.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(uid)
err = l.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(c, uid)
if err != nil {
log.BootErrorf("[user_data.DisableUserTwoFactorAuthorization] failed to delete two factor recovery codes for user \"%s\"", username)
log.CliErrorf(c, "[user_data.DisableUserTwoFactorAuthorization] failed to delete two-factor recovery codes for user \"%s\"", username)
return err
}
err = l.twoFactorAuthorizations.DeleteTwoFactorSetting(uid)
err = l.twoFactorAuthorizations.DeleteTwoFactorSetting(c, uid)
if err != nil {
log.BootErrorf("[user_data.DisableUserTwoFactorAuthorization] failed to delete two factor setting for user \"%s\"", username)
log.CliErrorf(c, "[user_data.DisableUserTwoFactorAuthorization] failed to delete two-factor setting for user \"%s\"", username)
return err
}
@@ -270,23 +420,23 @@ func (l *UserDataCli) DisableUserTwoFactorAuthorization(c *cli.Context, username
}
// CheckTransactionAndAccount checks whether all user transactions and all user accounts are correct
func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string) (bool, error) {
func (l *UserDataCli) CheckTransactionAndAccount(c *core.CliContext, username string) (bool, error) {
if username == "" {
log.BootErrorf("[user_data.CheckTransactionAndAccount] user name is empty")
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] user name is empty")
return false, errs.ErrUsernameIsEmpty
}
uid, err := l.getUserIdByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.CheckTransactionAndAccount] error occurs when getting user id by user name")
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] error occurs when getting user id by user name")
return false, err
}
accountMap, categoryMap, tagMap, tagIndexs, err := l.getUserEssentialData(uid, username)
accountMap, categoryMap, tagMap, tagIndexes, tagIndexesMap, err := l.getUserEssentialData(c, uid, username)
if err != nil {
log.BootErrorf("[user_data.CheckTransactionAndAccount] failed to get essential data for user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] failed to get essential data for user \"%s\", because %s", username, err.Error())
return false, err
}
@@ -298,10 +448,10 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
}
}
allTransactions, err := l.transactions.GetAllTransactions(uid, pageCountForGettingTransactions, false)
allTransactions, err := l.transactions.GetAllTransactions(c, uid, pageCountForGettingTransactions, false)
if err != nil {
log.BootErrorf("[user_data.CheckTransactionAndAccount] failed to all transactions for user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] failed to all transactions for user \"%s\", because %s", username, err.Error())
return false, err
}
@@ -323,7 +473,7 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
return false, err
}
err = l.checkTransactionTag(c, transaction.TransactionId, tagIndexs, tagMap)
err = l.checkTransactionTag(c, transaction.TransactionId, tagIndexesMap, tagMap)
if err != nil {
return false, err
@@ -352,7 +502,7 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
} else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
balance = balance + transaction.Amount
} else {
log.BootErrorf("[user_data.CheckAccountBalance] transaction type of transaction \"id:%d\" is invalid", transaction.TransactionId)
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] transaction type of transaction \"id:%d\" is invalid", transaction.TransactionId)
return false, errs.ErrOperationFailed
}
@@ -367,12 +517,12 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
}
if !exists && account.Balance != 0 {
log.BootErrorf("[user_data.CheckAccountBalance] account \"id:%d\" balance is not correct, expected balance is %d, but there is no transaction actually", account.AccountId, account.Balance)
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] account \"id:%d\" balance is not correct, expected balance is %d, but there is no transaction actually", account.AccountId, account.Balance)
return false, errs.ErrOperationFailed
}
if account.Balance != actualBalance {
log.BootErrorf("[user_data.CheckAccountBalance] account \"id:%d\" balance is not correct, expected balance is %d, but actual balance is %d", account.AccountId, account.Balance, actualBalance)
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] account \"id:%d\" balance is not correct, expected balance is %d, but actual balance is %d", account.AccountId, account.Balance, actualBalance)
return false, errs.ErrOperationFailed
}
}
@@ -381,7 +531,16 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
_, exists := accountMap[accountId]
if !exists {
log.BootErrorf("[user_data.CheckAccountBalance] account \"id:%d\" does not exist, but there are some transactions of this account actually, and actual balance is %d", accountId, actualBalance)
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] account \"id:%d\" does not exist, but there are some transactions of this account actually, and actual balance is %d", accountId, actualBalance)
return false, errs.ErrOperationFailed
}
}
for i := 0; i < len(tagIndexes); i++ {
tagIndex := tagIndexes[i]
if tagIndex.TransactionTime < 1 {
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] transaction tag index \"id:%d\" does not have transaction time", tagIndex.TagIndexId)
return false, errs.ErrOperationFailed
}
}
@@ -389,108 +548,304 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
return true, nil
}
// ExportTransaction returns csv file content according user all transactions
func (l *UserDataCli) ExportTransaction(c *cli.Context, username string) ([]byte, error) {
// FixTransactionTagIndexWithTransactionTime fixes user transaction tag index data with transaction time
func (l *UserDataCli) FixTransactionTagIndexWithTransactionTime(c *core.CliContext, username string) (bool, error) {
if username == "" {
log.BootErrorf("[user_data.ExportTransaction] user name is empty")
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] user name is empty")
return false, errs.ErrUsernameIsEmpty
}
uid, err := l.getUserIdByUsername(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] error occurs when getting user id by user name")
return false, err
}
tagIndexes, err := l.tags.GetAllTagIdsOfAllTransactions(c, uid)
if err != nil {
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] failed to get tag index for user \"%s\", because %s", username, err.Error())
return false, err
}
invalidTagIndexes := make([]*models.TransactionTagIndex, 0, len(tagIndexes))
for i := 0; i < len(tagIndexes); i++ {
tagIndex := tagIndexes[i]
if tagIndex.TransactionTime < 1 {
invalidTagIndexes = append(invalidTagIndexes, tagIndex)
}
}
if len(invalidTagIndexes) < 1 {
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] all user transaction tag index data has been checked, there is no problem with user data")
return false, errs.ErrOperationFailed
}
allTransactions, err := l.transactions.GetAllTransactions(c, uid, pageCountForGettingTransactions, false)
if err != nil {
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] failed to all transactions for user \"%s\", because %s", username, err.Error())
return false, err
}
transactionMap := l.transactions.GetTransactionMapByList(allTransactions)
for i := 0; i < len(invalidTagIndexes); i++ {
tagIndex := invalidTagIndexes[i]
transaction, exists := transactionMap[tagIndex.TransactionId]
if !exists {
continue
}
tagIndex.TransactionTime = transaction.TransactionTime
}
err = l.tags.ModifyTagIndexTransactionTime(c, uid, invalidTagIndexes)
if err != nil {
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] failed to update transaction tag index for user \"%s\", because %s", username, err.Error())
return false, err
}
return true, nil
}
// ExportTransaction returns csv file content according user all transactions
func (l *UserDataCli) ExportTransaction(c *core.CliContext, username string, fileType string) ([]byte, error) {
if username == "" {
log.CliErrorf(c, "[user_data.ExportTransaction] user name is empty")
return nil, errs.ErrUsernameIsEmpty
}
uid, err := l.getUserIdByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.ExportTransaction] error occurs when getting user id by user name")
log.CliErrorf(c, "[user_data.ExportTransaction] error occurs when getting user id by user name")
return nil, err
}
accountMap, categoryMap, tagMap, tagIndexs, err := l.getUserEssentialData(uid, username)
accountMap, categoryMap, tagMap, _, tagIndexesMap, err := l.getUserEssentialData(c, uid, username)
if err != nil {
log.BootErrorf("[user_data.ExportTransaction] failed to get essential data for user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.ExportTransaction] failed to get essential data for user \"%s\", because %s", username, err.Error())
return nil, err
}
allTransactions, err := l.transactions.GetAllTransactions(uid, pageCountForDataExport, true)
allTransactions, err := l.transactions.GetAllTransactions(c, uid, pageCountForDataExport, true)
if err != nil {
log.BootErrorf("[user_data.ExportTransaction] failed to all transactions for user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.ExportTransaction] failed to all transactions for user \"%s\", because %s", username, err.Error())
return nil, err
}
result, err := l.ezBookKeepingCsvExporter.ToExportedContent(uid, time.Local, allTransactions, accountMap, categoryMap, tagMap, tagIndexs)
dataExporter := converters.GetTransactionDataExporter(fileType)
if dataExporter == nil {
return nil, errs.ErrNotImplemented
}
result, err := dataExporter.ToExportedContent(c, uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexesMap)
if err != nil {
log.BootErrorf("[user_data.ExportTransaction] failed to get csv format exported data for \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.ExportTransaction] failed to get csv format exported data for \"%s\", because %s", username, err.Error())
return nil, err
}
return result, nil
}
func (l *UserDataCli) getUserIdByUsername(c *cli.Context, username string) (int64, error) {
func (l *UserDataCli) ImportTransaction(c *core.CliContext, username string, fileType string, data []byte) error {
if username == "" {
log.CliErrorf(c, "[user_data.ImportTransaction] user name is empty")
return errs.ErrUsernameIsEmpty
}
dataImporter, err := converters.GetTransactionDataImporter(fileType)
if err != nil {
return err
}
user, err := l.GetUserByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.getUserIdByUsername] failed to get user by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.ImportTransaction] failed to get user by user name \"%s\", because %s", username, err.Error())
return err
}
accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, err := l.getUserEssentialDataForImport(c, user.Uid, username)
if err != nil {
log.CliErrorf(c, "[user_data.ImportTransaction] failed to get essential data for user \"%s\", because %s", username, err.Error())
return err
}
parsedTransactions, newAccounts, newSubExpenseCategories, newSubIncomeCategories, newSubTransferCategories, newTags, err := dataImporter.ParseImportedData(c, user, data, utils.GetTimezoneOffsetMinutes(time.Local), accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
if err != nil {
log.CliErrorf(c, "[user_data.ImportTransaction] failed to parse imported data for \"%s\", because %s", username, err.Error())
return err
}
if len(parsedTransactions) < 1 {
log.CliErrorf(c, "[user_data.ImportTransaction] there are no transactions in import file")
return errs.ErrOperationFailed
}
if len(newAccounts) > 0 {
accountNames := l.accounts.GetAccountNames(newAccounts)
log.CliErrorf(c, "[user_data.ImportTransaction] there are %d accounts (%s) need to be created, please create them manually", len(newAccounts), strings.Join(accountNames, ","))
return errs.ErrOperationFailed
}
if len(newSubExpenseCategories) > 0 {
categoryNames := l.categories.GetCategoryNames(newSubExpenseCategories)
log.CliErrorf(c, "[user_data.ImportTransaction] there are %d expense categories (%s) need to be created, please create them manually", len(newSubExpenseCategories), strings.Join(categoryNames, ","))
return errs.ErrOperationFailed
}
if len(newSubIncomeCategories) > 0 {
categoryNames := l.categories.GetCategoryNames(newSubIncomeCategories)
log.CliErrorf(c, "[user_data.ImportTransaction] there are %d income categories (%s) need to be created, please create them manually", len(newSubIncomeCategories), strings.Join(categoryNames, ","))
return errs.ErrOperationFailed
}
if len(newSubTransferCategories) > 0 {
categoryNames := l.categories.GetCategoryNames(newSubTransferCategories)
log.CliErrorf(c, "[user_data.ImportTransaction] there are %d transfer categories (%s) need to be created, please create them manually", len(newSubTransferCategories), strings.Join(categoryNames, ","))
return errs.ErrOperationFailed
}
if len(newTags) > 0 {
tagNames := l.tags.GetTagNames(newTags)
log.CliErrorf(c, "[user_data.ImportTransaction] there are %d transaction tags (%s) need to be created, please create them manually", len(newTags), strings.Join(tagNames, ","))
return errs.ErrOperationFailed
}
newTransactions := parsedTransactions.ToTransactionsList()
newTransactionTagIdsMap, err := parsedTransactions.ToTransactionTagIdsMap()
if err != nil {
log.CliErrorf(c, "[user_data.ImportTransaction] failed to get transaction tag ids map, because %s", err.Error())
return errs.ErrOperationFailed
}
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap)
if err != nil {
log.CliErrorf(c, "[user_data.ImportTransaction] failed to create transaction, because %s", err.Error())
return err
}
return nil
}
func (l *UserDataCli) getUserIdByUsername(c *core.CliContext, username string) (int64, error) {
user, err := l.GetUserByUsername(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.getUserIdByUsername] failed to get user by user name \"%s\", because %s", username, err.Error())
return 0, err
}
return user.Uid, nil
}
func (l *UserDataCli) getUserEssentialData(uid int64, username string) (accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, tagIndexs map[int64][]int64, err error) {
func (l *UserDataCli) getUserEssentialData(c *core.CliContext, uid int64, username string) (accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, tagIndexes []*models.TransactionTagIndex, tagIndexesMap map[int64][]int64, err error) {
if uid <= 0 {
log.BootErrorf("[user_data.getUserEssentialData] user uid \"%d\" is invalid", uid)
return nil, nil, nil, nil, errs.ErrUserIdInvalid
log.CliErrorf(c, "[user_data.getUserEssentialData] user uid \"%d\" is invalid", uid)
return nil, nil, nil, nil, nil, errs.ErrUserIdInvalid
}
accounts, err := l.accounts.GetAllAccountsByUid(uid)
accounts, err := l.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.BootErrorf("[user_data.getUserEssentialData] failed to get accounts for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, err
log.CliErrorf(c, "[user_data.getUserEssentialData] failed to get accounts for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, nil, err
}
accountMap = l.accounts.GetAccountMapByList(accounts)
categories, err := l.categories.GetAllCategoriesByUid(uid, 0, -1)
categories, err := l.categories.GetAllCategoriesByUid(c, uid, 0, -1)
if err != nil {
log.BootErrorf("[user_data.getUserEssentialData] failed to get categories for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, err
log.CliErrorf(c, "[user_data.getUserEssentialData] failed to get categories for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, nil, err
}
categoryMap = l.categories.GetCategoryMapByList(categories)
tags, err := l.tags.GetAllTagsByUid(uid)
tags, err := l.tags.GetAllTagsByUid(c, uid)
if err != nil {
log.BootErrorf("[user_data.getUserEssentialData] failed to get tags for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, err
log.CliErrorf(c, "[user_data.getUserEssentialData] failed to get tags for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, nil, err
}
tagMap = l.tags.GetTagMapByList(tags)
tagIndexs, err = l.tags.GetAllTagIdsOfAllTransactions(uid)
tagIndexes, err = l.tags.GetAllTagIdsOfAllTransactions(c, uid)
if err != nil {
log.BootErrorf("[user_data.getUserEssentialData] failed to get tag index for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, err
log.CliErrorf(c, "[user_data.getUserEssentialData] failed to get tag index for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, nil, err
}
return accountMap, categoryMap, tagMap, tagIndexs, nil
tagIndexesMap = l.tags.GetGroupedTransactionTagIds(tagIndexes)
return accountMap, categoryMap, tagMap, tagIndexes, tagIndexesMap, nil
}
func (l *UserDataCli) checkTransactionAccount(c *cli.Context, transaction *models.Transaction, accountMap map[int64]*models.Account, accountHasChild map[int64]bool) error {
func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int64, username string) (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, err error) {
if uid <= 0 {
log.CliErrorf(c, "[user_data.getUserEssentialDataForImport] user uid \"%d\" is invalid", uid)
return nil, nil, nil, nil, nil, errs.ErrUserIdInvalid
}
accounts, err := l.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.CliErrorf(c, "[user_data.getUserEssentialDataForImport] failed to get accounts for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, nil, err
}
accountMap = l.accounts.GetAccountNameMapByList(accounts)
categories, err := l.categories.GetAllCategoriesByUid(c, uid, 0, -1)
if err != nil {
log.CliErrorf(c, "[user_data.getUserEssentialDataForImport] failed to get categories for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, nil, err
}
expenseCategoryMap, incomeCategoryMap, transferCategoryMap = l.categories.GetCategoryNameMapByList(categories)
tags, err := l.tags.GetAllTagsByUid(c, uid)
if err != nil {
log.CliErrorf(c, "[user_data.getUserEssentialDataForImport] failed to get tags for user \"%s\", because %s", username, err.Error())
return nil, nil, nil, nil, nil, err
}
tagMap = l.tags.GetTagNameMapByList(tags)
return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil
}
func (l *UserDataCli) checkTransactionAccount(c *core.CliContext, transaction *models.Transaction, accountMap map[int64]*models.Account, accountHasChild map[int64]bool) error {
account, exists := accountMap[transaction.AccountId]
if !exists {
log.BootErrorf("[user_data.checkTransactionAccount] the account \"id:%d\" of transaction \"id:%d\" does not exist", transaction.AccountId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionAccount] the account \"id:%d\" of transaction \"id:%d\" does not exist", transaction.AccountId, transaction.TransactionId)
return errs.ErrAccountNotFound
}
if account.ParentAccountId == models.LevelOneAccountParentId && accountHasChild[account.AccountId] {
log.BootErrorf("[user_data.checkTransactionAccount] the account \"id:%d\" of transaction \"id:%d\" is not a sub account", transaction.AccountId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionAccount] the account \"id:%d\" of transaction \"id:%d\" is not a sub-account", transaction.AccountId, transaction.TransactionId)
return errs.ErrOperationFailed
}
@@ -498,12 +853,12 @@ func (l *UserDataCli) checkTransactionAccount(c *cli.Context, transaction *model
relatedAccount, exists := accountMap[transaction.RelatedAccountId]
if !exists {
log.BootErrorf("[user_data.checkTransactionAccount] the related account \"id:%d\" of transaction \"id:%d\" does not exist", transaction.RelatedAccountId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionAccount] the related account \"id:%d\" of transaction \"id:%d\" does not exist", transaction.RelatedAccountId, transaction.TransactionId)
return errs.ErrAccountNotFound
}
if relatedAccount.ParentAccountId == models.LevelOneAccountParentId && accountHasChild[relatedAccount.AccountId] {
log.BootErrorf("[user_data.checkTransactionAccount] the related account \"id:%d\" of transaction \"id:%d\" is not a sub account", transaction.RelatedAccountId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionAccount] the related account \"id:%d\" of transaction \"id:%d\" is not a sub-account", transaction.RelatedAccountId, transaction.TransactionId)
return errs.ErrOperationFailed
}
}
@@ -511,10 +866,10 @@ func (l *UserDataCli) checkTransactionAccount(c *cli.Context, transaction *model
return nil
}
func (l *UserDataCli) checkTransactionCategory(c *cli.Context, transaction *models.Transaction, categoryMap map[int64]*models.TransactionCategory) error {
func (l *UserDataCli) checkTransactionCategory(c *core.CliContext, transaction *models.Transaction, categoryMap map[int64]*models.TransactionCategory) error {
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
if transaction.CategoryId > 0 {
log.BootErrorf("[user_data.checkTransactionCategory] transaction \"id:%d\" is balance modification transaction, but has category \"id:%d\"", transaction.TransactionId, transaction.CategoryId)
log.CliErrorf(c, "[user_data.checkTransactionCategory] transaction \"id:%d\" is balance modification transaction, but has category \"id:%d\"", transaction.TransactionId, transaction.CategoryId)
return errs.ErrBalanceModificationTransactionCannotSetCategory
} else {
return nil
@@ -524,31 +879,31 @@ func (l *UserDataCli) checkTransactionCategory(c *cli.Context, transaction *mode
category, exists := categoryMap[transaction.CategoryId]
if !exists {
log.BootErrorf("[user_data.checkTransactionCategory] the transaction category \"id:%d\" of transaction \"id:%d\" does not exist", transaction.CategoryId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionCategory] the transaction category \"id:%d\" of transaction \"id:%d\" does not exist", transaction.CategoryId, transaction.TransactionId)
return errs.ErrTransactionCategoryNotFound
}
if category.ParentCategoryId == models.LevelOneTransactionParentId {
log.BootErrorf("[user_data.checkTransactionCategory] the transaction category \"id:%d\" of transaction \"id:%d\" is not a sub category", transaction.CategoryId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionCategory] the transaction category \"id:%d\" of transaction \"id:%d\" is not a sub category", transaction.CategoryId, transaction.TransactionId)
return errs.ErrOperationFailed
}
return nil
}
func (l *UserDataCli) checkTransactionTag(c *cli.Context, transactionId int64, allTagIndexs map[int64][]int64, tagMap map[int64]*models.TransactionTag) error {
tagIndexs, exists := allTagIndexs[transactionId]
func (l *UserDataCli) checkTransactionTag(c *core.CliContext, transactionId int64, allTagIndexesMap map[int64][]int64, tagMap map[int64]*models.TransactionTag) error {
tagIndexes, exists := allTagIndexesMap[transactionId]
if !exists {
return nil
}
for i := 0; i < len(tagIndexs); i++ {
tagIndex := tagIndexs[i]
for i := 0; i < len(tagIndexes); i++ {
tagIndex := tagIndexes[i]
tag, exists := tagMap[tagIndex]
if !exists {
log.BootErrorf("[user_data.checkTransactionTag] the transaction tag \"id:%d\" of transaction \"id:%d\" does not exist", tag.TagId, transactionId)
log.CliErrorf(c, "[user_data.checkTransactionTag] the transaction tag \"id:%d\" of transaction \"id:%d\" does not exist", tag.TagId, transactionId)
return errs.ErrTransactionTagNotFound
}
}
@@ -556,7 +911,7 @@ func (l *UserDataCli) checkTransactionTag(c *cli.Context, transactionId int64, a
return nil
}
func (l *UserDataCli) checkTransactionRelatedTransaction(c *cli.Context, transaction *models.Transaction, transactionMap map[int64]*models.Transaction, accountMap map[int64]*models.Account) error {
func (l *UserDataCli) checkTransactionRelatedTransaction(c *core.CliContext, transaction *models.Transaction, transactionMap map[int64]*models.Transaction, accountMap map[int64]*models.Account) error {
if transaction.Type != models.TRANSACTION_DB_TYPE_TRANSFER_OUT && transaction.Type != models.TRANSACTION_DB_TYPE_TRANSFER_IN {
return nil
}
@@ -564,22 +919,22 @@ func (l *UserDataCli) checkTransactionRelatedTransaction(c *cli.Context, transac
relatedTransaction, exists := transactionMap[transaction.RelatedId]
if !exists {
log.BootErrorf("[user_data.checkTransactionRelatedTransaction] the related transaction \"id:%d\" of transaction \"id:%d\" does not exist", transaction.RelatedId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionRelatedTransaction] the related transaction \"id:%d\" of transaction \"id:%d\" does not exist", transaction.RelatedId, transaction.TransactionId)
return errs.ErrTransactionNotFound
}
if transaction.RelatedId != relatedTransaction.TransactionId || transaction.TransactionId != relatedTransaction.RelatedId {
log.BootErrorf("[user_data.checkTransactionRelatedTransaction] related ids of transaction \"id:%d\" and transaction \"id:%d\" are not equal", transaction.RelatedId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionRelatedTransaction] related ids of transaction \"id:%d\" and transaction \"id:%d\" are not equal", transaction.RelatedId, transaction.TransactionId)
return errs.ErrOperationFailed
}
if transaction.RelatedAccountId != relatedTransaction.AccountId || transaction.AccountId != relatedTransaction.RelatedAccountId {
log.BootErrorf("[user_data.checkTransactionRelatedTransaction] related account ids of transaction \"id:%d\" and transaction \"id:%d\" are not equal", transaction.RelatedId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionRelatedTransaction] related account ids of transaction \"id:%d\" and transaction \"id:%d\" are not equal", transaction.RelatedId, transaction.TransactionId)
return errs.ErrOperationFailed
}
if transaction.RelatedAccountAmount != relatedTransaction.Amount || transaction.Amount != relatedTransaction.RelatedAccountAmount {
log.BootErrorf("[user_data.checkTransactionRelatedTransaction] related amounts of transaction \"id:%d\" and transaction \"id:%d\" are not equal", transaction.RelatedId, transaction.TransactionId)
log.CliErrorf(c, "[user_data.checkTransactionRelatedTransaction] related amounts of transaction \"id:%d\" and transaction \"id:%d\" are not equal", transaction.RelatedId, transaction.TransactionId)
return errs.ErrOperationFailed
}
@@ -587,7 +942,7 @@ func (l *UserDataCli) checkTransactionRelatedTransaction(c *cli.Context, transac
relatedAccount := accountMap[transaction.RelatedAccountId]
if account.Currency == relatedAccount.Currency && transaction.Amount != transaction.RelatedAccountAmount {
log.BootWarnf("[user_data.checkTransactionRelatedTransaction] transfer-in amount and transfer-out amount of transaction \"id:%d\" are not equal", transaction.TransactionId)
log.CliWarnf(c, "[user_data.checkTransactionRelatedTransaction] transfer-in amount and transfer-out amount of transaction \"id:%d\" are not equal", transaction.TransactionId)
}
return nil
@@ -0,0 +1,27 @@
package alipay
// alipayAppTransactionDataCsvFileImporter defines the structure of alipay app csv importer for transaction data
type alipayAppTransactionDataCsvFileImporter struct {
alipayTransactionDataCsvFileImporter
}
// Initialize a alipay app transaction data csv file importer singleton instance
var (
AlipayAppTransactionDataCsvFileImporter = &alipayAppTransactionDataCsvFileImporter{
alipayTransactionDataCsvFileImporter{
fileHeaderLine: "------------------------------------------------------------------------------------",
dataHeaderStartContent: "支付宝(中国)网络技术有限公司 电子客户回单",
originalColumnNames: alipayTransactionColumnNames{
timeColumnName: "交易时间",
categoryColumnName: "交易分类",
targetNameColumnName: "交易对方",
productNameColumnName: "商品说明",
amountColumnName: "金额",
typeColumnName: "收/支",
relatedAccountColumnName: "收/付款方式",
statusColumnName: "交易状态",
descriptionColumnName: "备注",
},
},
}
)
@@ -0,0 +1,161 @@
package alipay
import (
"bytes"
"encoding/csv"
"io"
"strings"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
csvdatatable "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 alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
}
var alipayTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_INCOME: "收入",
models.TRANSACTION_TYPE_EXPENSE: "支出",
models.TRANSACTION_TYPE_TRANSFER: "不计收支",
}
// alipayTransactionColumnNames defines the structure of alipay transaction plain text header names
type alipayTransactionColumnNames struct {
timeColumnName string
categoryColumnName string
targetNameColumnName string
productNameColumnName string
amountColumnName string
typeColumnName string
relatedAccountColumnName string
statusColumnName string
descriptionColumnName string
}
// alipayTransactionDataCsvFileImporter defines the structure of alipay csv importer for transaction data
type alipayTransactionDataCsvFileImporter struct {
fileHeaderLine string
dataHeaderStartContent string
dataBottomEndLineRune rune
originalColumnNames alipayTransactionColumnNames
}
// ParseImportedData returns the imported data by parsing the alipay transaction csv data
func (c *alipayTransactionDataCsvFileImporter) 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) {
enc := simplifiedchinese.GB18030
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
dataTable, err := c.createNewAlipayImportedDataTable(ctx, reader, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
commonDataTable := datatable.CreateNewImportedCommonDataTable(dataTable)
if !commonDataTable.HasColumn(c.originalColumnNames.timeColumnName) ||
!commonDataTable.HasColumn(c.originalColumnNames.amountColumnName) ||
!commonDataTable.HasColumn(c.originalColumnNames.typeColumnName) ||
!commonDataTable.HasColumn(c.originalColumnNames.statusColumnName) {
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.ParseImportedData] cannot parse alipay csv data, because missing essential columns in header row")
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
transactionRowParser := createAlipayTransactionDataRowParser(c.originalColumnNames)
transactionDataTable := datatable.CreateNewCommonTransactionDataTable(commonDataTable, alipayTransactionSupportedColumns, transactionRowParser)
dataTableImporter := datatable.CreateNewSimpleImporter(alipayTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (c *alipayTransactionDataCsvFileImporter) createNewAlipayImportedDataTable(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.ImportedDataTable, error) {
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
allOriginalLines := make([][]string, 0)
hasFileHeader := false
foundContentBeforeDataHeaderLine := false
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse alipay csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if !hasFileHeader {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], fileHeaderLine) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
continue
}
}
if !foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], dataHeaderStartContent) >= 0 {
foundContentBeforeDataHeaderLine = true
continue
} else {
continue
}
}
if foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
} else if len(items) == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(items[0], dataBottomEndLineRune) {
break
}
for i := 0; i < len(items); i++ {
items[i] = strings.Trim(items[i], " ")
}
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse row \"index:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", len(allOriginalLines), len(items), len(allOriginalLines[0]))
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
allOriginalLines = append(allOriginalLines, items)
}
}
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
return nil, errs.ErrInvalidFileHeader
}
if len(allOriginalLines) < 2 {
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
dataTable := csvdatatable.CreateNewCustomCsvImportedDataTable(allOriginalLines)
return dataTable, nil
}
@@ -0,0 +1,614 @@
package alipay
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"golang.org/x/text/encoding/simplifiedchinese"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,交易成功 ,\n" +
"2024-09-01 12:34:56 ,xxxx ,123.45 ,支出 ,交易成功 ,\n" +
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易成功 ,\n" +
"2024-09-02 23:59:59 ,提现-普通提现 ,0.03 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[0].Amount)
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
assert.Equal(t, "2024-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(12345), allNewTransactions[1].Amount)
assert.Equal(t, "", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
assert.Equal(t, "2024-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
assert.Equal(t, int64(5), allNewTransactions[2].Amount)
assert.Equal(t, "", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Alipay", allNewTransactions[2].OriginalDestinationAccountName)
assert.Equal(t, "", 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, "2024-09-02 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
assert.Equal(t, int64(3), allNewTransactions[3].Amount)
assert.Equal(t, "Alipay", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Alipay", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
}
func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,0.12 ,不计收支 ,退款成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(-12), allNewTransactions[0].Amount)
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,0.12 ,收入 ,退税成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(-12), allNewTransactions[0].Amount)
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01T12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"09/01/2024 12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,0.12 , ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
// income to alipay wallet
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
// refund to other account
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,0.12 ,不计收支 ,退款成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
// transfer to alipay wallet
data3, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,充值-普通充值 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalDestinationAccountName)
// transfer from alipay wallet
data4, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,提现-实时提现 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
// transfer in
data5, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,xx-转入 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
// transfer out
data6, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,xx-转出 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data6), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
// repayment
data7, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,xx还款 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data7), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
}
func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
converter := AlipayAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
"导出信息:\n" +
"姓名:xxx\n" +
"支付宝账户:xxx@xxx.xxx\n" +
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
"导出交易类型:[全部]\n" +
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
"交易时间,交易分类,商品说明,收/支,金额,交易状态,\n" +
"2024-09-01 01:23:45,Test Category,xxxx,收入,0.12,交易成功,\n" +
"2024-09-01 12:34:56,Test Category2,xxxx,支出,123.45,交易成功,\n" +
"2024-09-01 23:59:59,Test Category3,充值-普通充值,不计收支,0.05,交易成功,\n")
assert.Nil(t, err)
allNewTransactions, _, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 3, len(allNewTransactions))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
}
func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T) {
converter := AlipayAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
"导出信息:\n" +
"姓名:xxx\n" +
"支付宝账户:xxx@xxx.xxx\n" +
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
"导出交易类型:[全部]\n" +
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
"交易时间,商品说明,收/支,金额,收/付款方式,交易状态,\n" +
"2024-09-01 03:45:07,余额宝-单次转入,不计收支,0.01,Test Account,交易成功,\n" +
"2024-09-01 05:07:29,信用卡还款,不计收支,0.02,Test Account2,交易成功,\n")
assert.Nil(t, err)
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 3, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, int64(2), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalDestinationAccountName)
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, "", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[2].Name)
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
}
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,备注 ,\n" +
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 ,test2 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "test2", allNewTransactions[0].Comment)
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,备注 ,\n" +
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 , ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "test", allNewTransactions[0].Comment)
}
func TestAlipayCsvFileImporterParseImportedData_SkipClosedIncomeOrTransferTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,交易关闭 ,\n" +
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易关闭 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownProductTransferTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 23:59:59 ,xxxx ,0.05 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,xxxx ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String(
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
}
func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Column
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"金额(元),收/支 ,交易状态 ,\n" +
"0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Status Column
data3, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,\n" +
"2024-09-01 12:34:56 ,0.12 ,收入 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
data4, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),交易状态 ,\n" +
"2024-09-01 12:34:56 ,0.12 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
func TestAlipayCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
@@ -0,0 +1,178 @@
package alipay
import (
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/locales"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const alipayTransactionDataStatusSuccessName = "交易成功"
const alipayTransactionDataStatusPaymentSuccessName = "支付成功"
const alipayTransactionDataStatusRepaymentSuccessName = "还款成功"
const alipayTransactionDataStatusClosedName = "交易关闭"
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
const alipayTransactionDataProductNameTransferInText = "转入"
const alipayTransactionDataProductNameTransferOutText = "转出"
const alipayTransactionDataProductNameRepaymentText = "还款"
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
type alipayTransactionDataRowParser struct {
columns alipayTransactionColumnNames
}
// Parse returns the converted transaction data row
func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataTable *datatable.CommonTransactionDataTable, dataRow datatable.CommonDataRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
if dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because type is \"%s\"", rowId, dataRow.GetData(p.columns.typeColumnName))
return nil, false, nil
}
if dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusPaymentSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRepaymentSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusClosedName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRefundSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusTaxRefundSuccessName {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because status is \"%s\"", rowId, dataRow.GetData(p.columns.statusColumnName))
return nil, false, nil
}
data := make(map[datatable.TransactionDataTableColumn]string, len(alipayTransactionSupportedColumns))
if dataTable.HasOriginalColumn(p.columns.timeColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData(p.columns.timeColumnName)
}
if dataTable.HasOriginalColumn(p.columns.categoryColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(p.columns.categoryColumnName)
} else {
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
}
if dataTable.HasOriginalColumn(p.columns.amountColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData(p.columns.amountColumnName)
}
if dataTable.HasOriginalColumn(p.columns.descriptionColumnName) && dataRow.GetData(p.columns.descriptionColumnName) != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(p.columns.descriptionColumnName)
} else if dataTable.HasOriginalColumn(p.columns.productNameColumnName) && dataRow.GetData(p.columns.productNameColumnName) != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(p.columns.productNameColumnName)
} else {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
}
relatedAccountName := ""
if dataTable.HasOriginalColumn(p.columns.relatedAccountColumnName) {
relatedAccountName = dataRow.GetData(p.columns.relatedAccountColumnName)
}
statusName := ""
if dataTable.HasOriginalColumn(p.columns.statusColumnName) {
statusName = dataRow.GetData(p.columns.statusColumnName)
}
locale := user.Language
if locale == "" {
locale = ctx.GetClientLocale()
}
localeTextItems := locales.GetLocaleTextItems(locale)
if dataTable.HasOriginalColumn(p.columns.typeColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData(p.columns.typeColumnName)
if dataRow.GetData(p.columns.typeColumnName) == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
if statusName == alipayTransactionDataStatusClosedName {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because income transaction is closed", rowId)
return nil, false, nil
}
if statusName == alipayTransactionDataStatusSuccessName {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
} else {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
} else if dataRow.GetData(p.columns.typeColumnName) == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
if statusName == alipayTransactionDataStatusClosedName {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because non-income/expense transaction is closed", rowId)
return nil, false, nil
}
targetName := ""
productName := ""
if dataTable.HasOriginalColumn(p.columns.targetNameColumnName) {
targetName = dataRow.GetData(p.columns.targetNameColumnName)
}
if dataTable.HasOriginalColumn(p.columns.productNameColumnName) {
productName = dataRow.GetData(p.columns.productNameColumnName)
}
if statusName == alipayTransactionDataStatusRefundSuccessName {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
} else {
if strings.Index(productName, alipayTransactionDataProductNameTransferToAlipayPrefix) == 0 { // transfer to alipay wallet
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
} else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferInText) >= 0 { // transfer in
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because product name (\"%s\") is unknown", rowId, productName)
return nil, false, nil
}
}
} else {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
}
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] && statusName != "" {
if statusName == alipayTransactionDataStatusRefundSuccessName || statusName == alipayTransactionDataStatusTaxRefundSuccessName {
amount, err := utils.ParseAmount(data[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
if err == nil {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
}
}
return data, true, nil
}
// createAlipayTransactionDataRowParser returns alipay transaction data row parser
func createAlipayTransactionDataRowParser(originalColumnNames alipayTransactionColumnNames) datatable.CommonTransactionDataRowParser {
return &alipayTransactionDataRowParser{
columns: originalColumnNames,
}
}
@@ -0,0 +1,28 @@
package alipay
// alipayWebTransactionDataCsvFileImporter defines the structure of alipay (web) csv importer for transaction data
type alipayWebTransactionDataCsvFileImporter struct {
alipayTransactionDataCsvFileImporter
}
// Initialize a alipay (web) transaction data csv file importer singleton instance
var (
AlipayWebTransactionDataCsvFileImporter = &alipayWebTransactionDataCsvFileImporter{
alipayTransactionDataCsvFileImporter{
fileHeaderLine: "支付宝交易记录明细查询",
dataHeaderStartContent: "交易记录明细列表",
dataBottomEndLineRune: '-',
originalColumnNames: alipayTransactionColumnNames{
timeColumnName: "交易创建时间",
categoryColumnName: "",
targetNameColumnName: "交易对方",
productNameColumnName: "商品名称",
amountColumnName: "金额(元)",
typeColumnName: "收/支",
relatedAccountColumnName: "",
statusColumnName: "交易状态",
descriptionColumnName: "备注",
},
},
}
)
@@ -0,0 +1,24 @@
package base
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// TransactionDataExporter defines the structure of transaction data exporter
type TransactionDataExporter interface {
// ToExportedContent returns the exported data
ToExportedContent(ctx core.Context, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error)
}
// TransactionDataImporter defines the structure of transaction data importer
type TransactionDataImporter interface {
// ParseImportedData returns the imported data
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)
}
// TransactionDataConverter defines the structure of transaction data converter
type TransactionDataConverter interface {
TransactionDataExporter
TransactionDataImporter
}
@@ -0,0 +1,138 @@
package csv
import (
"encoding/csv"
"fmt"
"io"
"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"
)
// CsvFileImportedDataTable defines the structure of csv data table
type CsvFileImportedDataTable struct {
allLines [][]string
}
// CsvFileImportedDataRow defines the structure of csv data table row
type CsvFileImportedDataRow struct {
dataTable *CsvFileImportedDataTable
allItems []string
}
// CsvFileImportedDataRowIterator defines the structure of csv data table row iterator
type CsvFileImportedDataRowIterator struct {
dataTable *CsvFileImportedDataTable
currentIndex int
}
// DataRowCount returns the total count of data row
func (t *CsvFileImportedDataTable) DataRowCount() int {
if len(t.allLines) < 1 {
return 0
}
return len(t.allLines) - 1
}
// HeaderColumnNames returns the header column name list
func (t *CsvFileImportedDataTable) HeaderColumnNames() []string {
if len(t.allLines) < 1 {
return nil
}
return t.allLines[0]
}
// DataRowIterator returns the iterator of data row
func (t *CsvFileImportedDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
return &CsvFileImportedDataRowIterator{
dataTable: t,
currentIndex: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *CsvFileImportedDataRow) ColumnCount() int {
return len(r.allItems)
}
// GetData returns the data in the specified column index
func (r *CsvFileImportedDataRow) GetData(columnIndex int) string {
if columnIndex >= len(r.allItems) {
return ""
}
return r.allItems[columnIndex]
}
// HasNext returns whether the iterator does not reach the end
func (t *CsvFileImportedDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allLines)
}
// CurrentRowId returns current index
func (t *CsvFileImportedDataRowIterator) CurrentRowId() string {
return fmt.Sprintf("line#%d", t.currentIndex)
}
// Next returns the next imported data row
func (t *CsvFileImportedDataRowIterator) Next() datatable.ImportedDataRow {
if t.currentIndex+1 >= len(t.dataTable.allLines) {
return nil
}
t.currentIndex++
rowItems := t.dataTable.allLines[t.currentIndex]
return &CsvFileImportedDataRow{
dataTable: t.dataTable,
allItems: rowItems,
}
}
// CreateNewCsvImportedDataTable returns comma separated values data table by io readers
func CreateNewCsvImportedDataTable(ctx core.Context, reader io.Reader) (*CsvFileImportedDataTable, error) {
return createNewCsvFileDataTable(ctx, reader, ',')
}
// CreateNewCustomCsvImportedDataTable returns character separated values data table by io readers
func CreateNewCustomCsvImportedDataTable(allLines [][]string) *CsvFileImportedDataTable {
return &CsvFileImportedDataTable{
allLines: allLines,
}
}
func createNewCsvFileDataTable(ctx core.Context, reader io.Reader, separator rune) (*CsvFileImportedDataTable, error) {
csvReader := csv.NewReader(reader)
csvReader.Comma = separator
csvReader.FieldsPerRecord = -1
allLines := make([][]string, 0)
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[csv_file_imported_data_table.createNewCsvFileDataTable] cannot parse csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if len(items) == 1 && items[0] == "" {
continue
}
allLines = append(allLines, items)
}
return &CsvFileImportedDataTable{
allLines: allLines,
}, nil
}
@@ -0,0 +1,184 @@
package csv
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
)
func TestCsvFileImportedDataTableDataRowCount(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
assert.Equal(t, 2, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
})
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableDataRowCount_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{})
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableHeaderColumnNames(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
}
func TestCsvFileImportedDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{})
assert.Nil(t, datatable.HeaderColumnNames())
}
func TestCsvFileImportedDataRowIterator(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
// data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// data row 2
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 3
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestCsvFileImportedDataRowColumnCount(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.EqualValues(t, 3, row1.ColumnCount())
row2 := iterator.Next()
assert.EqualValues(t, 3, row2.ColumnCount())
}
func TestCsvFileImportedDataRowGetData(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
}
func TestCsvFileImportedDataRowGetData_GetNotExistedColumnData(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "", row1.GetData(3))
}
func TestCreateNewCsvImportedDataTable(t *testing.T) {
context := core.NewNullContext()
reader := bytes.NewReader([]byte("A1,B1,C1\n" +
"A2,B2,C2\n" +
"A3,B3,C3\n"))
datatable, err := CreateNewCsvImportedDataTable(context, reader)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
row1 := iterator.Next()
assert.EqualValues(t, 3, row1.ColumnCount())
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
assert.True(t, iterator.HasNext())
row2 := iterator.Next()
assert.EqualValues(t, 3, row2.ColumnCount())
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
assert.False(t, iterator.HasNext())
}
func TestCreateNewCsvImportedDataTable_SkipBlankLine(t *testing.T) {
context := core.NewNullContext()
reader := bytes.NewReader([]byte("\n" +
"A1,B1,C1\n" +
"A2,B2,C2\n" +
"\n" +
"A3,B3,C3\n"))
datatable, err := CreateNewCsvImportedDataTable(context, reader)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
row1 := iterator.Next()
assert.EqualValues(t, 3, row1.ColumnCount())
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
assert.True(t, iterator.HasNext())
row2 := iterator.Next()
assert.EqualValues(t, 3, row2.ColumnCount())
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
assert.False(t, iterator.HasNext())
}
-13
View File
@@ -1,13 +0,0 @@
package converters
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// DataConverter defines the structure of data exporter
type DataConverter interface {
// ToExportedContent returns the exported data
ToExportedContent(uid int64, timezone *time.Location, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexs map[int64][]int64) ([]byte, error)
}
@@ -0,0 +1,40 @@
package datatable
// CommonDataTable defines the structure of common data table
type CommonDataTable interface {
// HeaderColumnCount returns the total count of column in header row
HeaderColumnCount() int
// HasColumn returns whether the common data table has specified column name
HasColumn(columnName string) bool
// DataRowCount returns the total count of common data row
DataRowCount() int
// DataRowIterator returns the iterator of common data row
DataRowIterator() CommonDataRowIterator
}
// CommonDataRow defines the structure of common data row
type CommonDataRow interface {
// ColumnCount returns the total count of column in this data row
ColumnCount() int
// HasData returns whether the common data row has specified column data
HasData(columnName string) bool
// GetData returns the data in the specified column name
GetData(columnName string) string
}
// CommonDataRowIterator defines the structure of common data row iterator
type CommonDataRowIterator interface {
// HasNext returns whether the iterator does not reach the end
HasNext() bool
// CurrentRowId returns current row id
CurrentRowId() string
// Next returns the next common data row
Next() CommonDataRow
}
@@ -0,0 +1,114 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// CommonTransactionDataTable defines the structure of common transaction data table
type CommonTransactionDataTable struct {
innerDataTable CommonDataTable
supportedDataColumns map[TransactionDataTableColumn]bool
rowParser CommonTransactionDataRowParser
}
// CommonTransactionDataRow defines the structure of common transaction data row
type CommonTransactionDataRow struct {
transactionDataTable *CommonTransactionDataTable
rowData map[TransactionDataTableColumn]string
rowDataValid bool
}
// CommonTransactionDataRowIterator defines the structure of common transaction data row iterator
type CommonTransactionDataRowIterator struct {
transactionDataTable *CommonTransactionDataTable
innerIterator CommonDataRowIterator
}
// CommonTransactionDataRowParser defines the structure of common transaction data row parser
type CommonTransactionDataRowParser interface {
// Parse returns the converted transaction data row
Parse(ctx core.Context, user *models.User, dataTable *CommonTransactionDataTable, dataRow CommonDataRow, rowId string) (rowData map[TransactionDataTableColumn]string, rowDataValid bool, err error)
}
// HasColumn returns whether the data table has specified column
func (t *CommonTransactionDataTable) HasColumn(column TransactionDataTableColumn) bool {
_, exists := t.supportedDataColumns[column]
return exists
}
// HasOriginalColumn returns whether the original data table has specified column name
func (t *CommonTransactionDataTable) HasOriginalColumn(columnName string) bool {
return columnName != "" && t.innerDataTable.HasColumn(columnName)
}
// TransactionRowCount returns the total count of transaction data row
func (t *CommonTransactionDataTable) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *CommonTransactionDataTable) TransactionRowIterator() TransactionDataRowIterator {
return &CommonTransactionDataRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *CommonTransactionDataRow) IsValid() bool {
return r.rowDataValid
}
// GetData returns the data in the specified column type
func (r *CommonTransactionDataRow) GetData(column TransactionDataTableColumn) string {
if !r.rowDataValid {
return ""
}
_, exists := r.transactionDataTable.supportedDataColumns[column]
if !exists {
return ""
}
return r.rowData[column]
}
// HasNext returns whether the iterator does not reach the end
func (t *CommonTransactionDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// Next returns the next transaction data row
func (t *CommonTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error) {
commonRow := t.innerIterator.Next()
if commonRow == nil {
return nil, nil
}
rowId := t.innerIterator.CurrentRowId()
rowData, rowDataValid, err := t.transactionDataTable.rowParser.Parse(ctx, user, t.transactionDataTable, commonRow, rowId)
if err != nil {
log.Errorf(ctx, "[common_transaction_data_table.Next] cannot parse data row, because %s", err.Error())
return nil, err
}
return &CommonTransactionDataRow{
transactionDataTable: t.transactionDataTable,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// CreateNewCommonTransactionDataTable returns transaction data table from Common data table
func CreateNewCommonTransactionDataTable(dataTable CommonDataTable, supportedDataColumns map[TransactionDataTableColumn]bool, rowParser CommonTransactionDataRowParser) *CommonTransactionDataTable {
return &CommonTransactionDataTable{
innerDataTable: dataTable,
supportedDataColumns: supportedDataColumns,
rowParser: rowParser,
}
}
@@ -0,0 +1,572 @@
package datatable
import (
"fmt"
"sort"
"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"
)
// DataTableTransactionDataExporter defines the structure of plain text data table exporter for transaction data
type DataTableTransactionDataExporter struct {
transactionTypeMapping map[models.TransactionType]string
geoLocationSeparator string
transactionTagSeparator string
}
// DataTableTransactionDataImporter defines the structure of plain text data table importer for transaction data
type DataTableTransactionDataImporter struct {
transactionTypeMapping map[models.TransactionType]string
geoLocationSeparator string
transactionTagSeparator string
}
// CreateNewExporter returns a new data table transaction data exporter according to the specified arguments
func CreateNewExporter(transactionTypeMapping map[models.TransactionType]string, geoLocationSeparator string, transactionTagSeparator string) *DataTableTransactionDataExporter {
return &DataTableTransactionDataExporter{
transactionTypeMapping: transactionTypeMapping,
geoLocationSeparator: geoLocationSeparator,
transactionTagSeparator: transactionTagSeparator,
}
}
// CreateNewImporter returns a new data table transaction data importer according to the specified arguments
func CreateNewImporter(transactionTypeMapping map[models.TransactionType]string, geoLocationSeparator string, transactionTagSeparator string) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
transactionTypeMapping: transactionTypeMapping,
geoLocationSeparator: geoLocationSeparator,
transactionTagSeparator: transactionTagSeparator,
}
}
// CreateNewSimpleImporter returns a new data table transaction data importer according to the specified arguments
func CreateNewSimpleImporter(transactionTypeMapping map[models.TransactionType]string) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
transactionTypeMapping: transactionTypeMapping,
}
}
// BuildExportedContent writes the exported transaction data to the data table builder
func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context, dataTableBuilder TransactionDataTableBuilder, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) error {
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
continue
}
dataRowMap := make(map[TransactionDataTableColumn]string, 15)
transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60)
dataRowMap[TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone)
dataRowMap[TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(transactionTimeZone)
dataRowMap[TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataTableBuilder.ReplaceDelimiters(c.getDisplayTransactionTypeName(transaction.Type))
dataRowMap[TRANSACTION_DATA_TABLE_CATEGORY] = c.getExportedTransactionCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
dataRowMap[TRANSACTION_DATA_TABLE_SUB_CATEGORY] = c.getExportedTransactionSubCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
dataRowMap[TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount)
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
dataRowMap[TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
}
dataRowMap[TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = c.getExportedGeographicLocation(transaction)
dataRowMap[TRANSACTION_DATA_TABLE_TAGS] = c.getExportedTags(dataTableBuilder, transaction.TransactionId, allTagIndexes, tagMap)
dataRowMap[TRANSACTION_DATA_TABLE_DESCRIPTION] = dataTableBuilder.ReplaceDelimiters(transaction.Comment)
dataTableBuilder.AppendTransaction(dataRowMap)
}
return nil
}
func (c *DataTableTransactionDataExporter) getDisplayTransactionTypeName(transactionDbType models.TransactionDbType) string {
transactionType, err := transactionDbType.ToTransactionType()
if err != nil {
return ""
}
transactionTypeName, exists := c.transactionTypeMapping[transactionType]
if !exists {
return ""
}
return transactionTypeName
}
func (c *DataTableTransactionDataExporter) getExportedTransactionCategoryName(dataTableBuilder TransactionDataTableBuilder, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if !exists {
return ""
}
if category.ParentCategoryId == 0 {
return dataTableBuilder.ReplaceDelimiters(category.Name)
}
parentCategory, exists := categoryMap[category.ParentCategoryId]
if !exists {
return ""
}
return dataTableBuilder.ReplaceDelimiters(parentCategory.Name)
}
func (c *DataTableTransactionDataExporter) getExportedTransactionSubCategoryName(dataTableBuilder TransactionDataTableBuilder, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if exists {
return dataTableBuilder.ReplaceDelimiters(category.Name)
} else {
return ""
}
}
func (c *DataTableTransactionDataExporter) getExportedAccountName(dataTableBuilder TransactionDataTableBuilder, accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return dataTableBuilder.ReplaceDelimiters(account.Name)
} else {
return ""
}
}
func (c *DataTableTransactionDataExporter) getAccountCurrency(dataTableBuilder TransactionDataTableBuilder, accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return dataTableBuilder.ReplaceDelimiters(account.Currency)
} else {
return ""
}
}
func (c *DataTableTransactionDataExporter) getExportedGeographicLocation(transaction *models.Transaction) string {
if transaction.GeoLongitude != 0 || transaction.GeoLatitude != 0 {
return fmt.Sprintf("%f%s%f", transaction.GeoLongitude, c.geoLocationSeparator, transaction.GeoLatitude)
}
return ""
}
func (c *DataTableTransactionDataExporter) getExportedTags(dataTableBuilder TransactionDataTableBuilder, transactionId int64, allTagIndexes map[int64][]int64, tagMap map[int64]*models.TransactionTag) string {
tagIndexes, exists := allTagIndexes[transactionId]
if !exists {
return ""
}
var ret strings.Builder
for i := 0; i < len(tagIndexes); i++ {
tagIndex := tagIndexes[i]
tag, exists := tagMap[tagIndex]
if !exists {
continue
}
if ret.Len() > 0 {
ret.WriteString(c.transactionTagSeparator)
}
ret.WriteString(strings.Replace(tag.Name, c.transactionTagSeparator, " ", -1))
}
return dataTableBuilder.ReplaceDelimiters(ret.String())
}
// ParseImportedData returns the imported transaction data
func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, dataTable TransactionDataTable, 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) {
if dataTable.TransactionRowCount() < 1 {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse import data for user \"uid:%d\", because data table row count is less 1", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
}
nameDbTypeMap, err := c.buildTransactionTypeNameDbTypeMap()
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
if !dataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME) ||
!dataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE) ||
!dataTable.HasColumn(TRANSACTION_DATA_TABLE_SUB_CATEGORY) ||
!dataTable.HasColumn(TRANSACTION_DATA_TABLE_ACCOUNT_NAME) ||
!dataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT) ||
!dataTable.HasColumn(TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME) {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse import data for user \"uid:%d\", because missing essential columns in header row", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if accountMap == nil {
accountMap = make(map[string]*models.Account)
}
if expenseCategoryMap == nil {
expenseCategoryMap = make(map[string]*models.TransactionCategory)
}
if incomeCategoryMap == nil {
incomeCategoryMap = make(map[string]*models.TransactionCategory)
}
if transferCategoryMap == nil {
transferCategoryMap = make(map[string]*models.TransactionCategory)
}
if tagMap == nil {
tagMap = make(map[string]*models.TransactionTag)
}
allNewTransactions := make(models.ImportedTransactionSlice, 0, dataTable.TransactionRowCount())
allNewAccounts := make([]*models.Account, 0)
allNewSubExpenseCategories := make([]*models.TransactionCategory, 0)
allNewSubIncomeCategories := make([]*models.TransactionCategory, 0)
allNewSubTransferCategories := make([]*models.TransactionCategory, 0)
allNewTags := make([]*models.TransactionTag, 0)
dataRowIterator := dataTable.TransactionRowIterator()
dataRowIndex := 0
for dataRowIterator.HasNext() {
dataRowIndex++
dataRow, err := dataRowIterator.Next(ctx, user)
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse data row \"index:%d\" for user \"uid:%d\", because %s", dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, err
}
if !dataRow.IsValid() {
continue
}
timezoneOffset := defaultTimezoneOffset
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) {
transactionTimezone, err := utils.ParseFromTimezoneOffset(dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse time zone \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrTransactionTimeZoneInvalid
}
timezoneOffset = utils.GetTimezoneOffsetMinutes(transactionTimezone)
}
transactionTime, err := utils.ParseFromLongDateTime(dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME), timezoneOffset)
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse time \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrTransactionTimeInvalid
}
transactionDbType, err := c.getTransactionDbType(nameDbTypeMap, dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse transaction type \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.Or(err, errs.ErrTransactionTypeInvalid)
}
categoryId := int64(0)
subCategoryName := ""
if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionCategoryType, err := c.getTransactionCategoryType(transactionDbType)
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse transaction category type in data row \"index:%d\" for user \"uid:%d\", because %s", dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.Or(err, errs.ErrTransactionTypeInvalid)
}
subCategoryName = dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
if transactionDbType == models.TRANSACTION_DB_TYPE_EXPENSE {
subCategory, exists := expenseCategoryMap[subCategoryName]
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubExpenseCategories = append(allNewSubExpenseCategories, subCategory)
expenseCategoryMap[subCategoryName] = subCategory
}
categoryId = subCategory.CategoryId
} else if transactionDbType == models.TRANSACTION_DB_TYPE_INCOME {
subCategory, exists := incomeCategoryMap[subCategoryName]
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubIncomeCategories = append(allNewSubIncomeCategories, subCategory)
incomeCategoryMap[subCategoryName] = subCategory
}
categoryId = subCategory.CategoryId
} else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
subCategory, exists := transferCategoryMap[subCategoryName]
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubTransferCategories = append(allNewSubTransferCategories, subCategory)
transferCategoryMap[subCategoryName] = subCategory
}
categoryId = subCategory.CategoryId
}
}
accountName := dataRow.GetData(TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
accountCurrency := user.DefaultCurrency
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY) {
accountCurrency = dataRow.GetData(TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY)
if _, ok := validators.AllCurrencyNames[accountCurrency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", accountCurrency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account, exists := accountMap[accountName]
if !exists {
account = c.createNewAccountModel(user.Uid, accountName, accountCurrency)
allNewAccounts = append(allNewAccounts, account)
accountMap[accountName] = account
}
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY) {
if account.Name != "" && account.Currency != accountCurrency {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account for user \"uid:%d\"", accountCurrency, dataRowIndex, account.Currency, user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
} else if exists {
accountCurrency = account.Currency
}
amount, err := utils.ParseAmount(dataRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse acmount \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrAmountInvalid
}
relatedAccountId := int64(0)
relatedAccountAmount := int64(0)
account2Name := ""
account2Currency := ""
if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2Name = dataRow.GetData(TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME)
account2Currency = user.DefaultCurrency
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY) {
account2Currency = dataRow.GetData(TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY)
if _, ok := validators.AllCurrencyNames[account2Currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account2 currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", account2Currency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account2, exists := accountMap[account2Name]
if !exists {
account2 = c.createNewAccountModel(user.Uid, account2Name, account2Currency)
allNewAccounts = append(allNewAccounts, account2)
accountMap[account2Name] = account2
}
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY) {
if account2.Name != "" && account2.Currency != account2Currency {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account2 for user \"uid:%d\"", account2Currency, dataRowIndex, account2.Currency, user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
} else if exists {
account2Currency = account2.Currency
}
relatedAccountId = account2.AccountId
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_RELATED_AMOUNT) {
relatedAccountAmount, err = utils.ParseAmount(dataRow.GetData(TRANSACTION_DATA_TABLE_RELATED_AMOUNT))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse acmount2 \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(TRANSACTION_DATA_TABLE_RELATED_AMOUNT), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrAmountInvalid
}
} else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
relatedAccountAmount = amount
}
}
geoLongitude := float64(0)
geoLatitude := float64(0)
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) {
geoLocationItems := strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), c.geoLocationSeparator)
if len(geoLocationItems) == 2 {
geoLongitude, err = utils.StringToFloat64(geoLocationItems[0])
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse geographic location \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrGeographicLocationInvalid
}
geoLatitude, err = utils.StringToFloat64(geoLocationItems[1])
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse geographic location \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrGeographicLocationInvalid
}
}
}
var tagIds []string
var tagNames []string
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_TAGS) {
tagNameItems := strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
for i := 0; i < len(tagNameItems); i++ {
tagName := tagNameItems[i]
if tagName == "" {
continue
}
tag, exists := tagMap[tagName]
if !exists {
tag = c.createNewTransactionTagModel(user.Uid, tagName)
allNewTags = append(allNewTags, tag)
tagMap[tagName] = tag
}
if tag != nil {
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
}
tagNames = append(tagNames, tagName)
}
}
description := ""
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION) {
description = dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
}
transaction := &models.ImportTransaction{
Transaction: &models.Transaction{
Uid: user.Uid,
Type: transactionDbType,
CategoryId: categoryId,
TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()),
TimezoneUtcOffset: timezoneOffset,
AccountId: account.AccountId,
Amount: amount,
HideAmount: false,
RelatedAccountId: relatedAccountId,
RelatedAccountAmount: relatedAccountAmount,
Comment: description,
GeoLongitude: geoLongitude,
GeoLatitude: geoLatitude,
CreatedIp: "127.0.0.1",
},
TagIds: tagIds,
OriginalCategoryName: subCategoryName,
OriginalSourceAccountName: accountName,
OriginalSourceAccountCurrency: accountCurrency,
OriginalDestinationAccountName: account2Name,
OriginalDestinationAccountCurrency: account2Currency,
OriginalTagNames: tagNames,
}
allNewTransactions = append(allNewTransactions, transaction)
}
if len(allNewTransactions) < 1 {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] no transaction data parsed for \"uid:%d\"", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
}
sort.Sort(allNewTransactions)
return allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, nil
}
func (c *DataTableTransactionDataImporter) buildTransactionTypeNameDbTypeMap() (map[string]models.TransactionDbType, error) {
if c.transactionTypeMapping == nil {
return nil, errs.ErrTransactionTypeInvalid
}
nameDbTypeMap := make(map[string]models.TransactionDbType, len(c.transactionTypeMapping))
nameDbTypeMap[c.transactionTypeMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]] = models.TRANSACTION_DB_TYPE_MODIFY_BALANCE
nameDbTypeMap[c.transactionTypeMapping[models.TRANSACTION_TYPE_INCOME]] = models.TRANSACTION_DB_TYPE_INCOME
nameDbTypeMap[c.transactionTypeMapping[models.TRANSACTION_TYPE_EXPENSE]] = models.TRANSACTION_DB_TYPE_EXPENSE
nameDbTypeMap[c.transactionTypeMapping[models.TRANSACTION_TYPE_TRANSFER]] = models.TRANSACTION_DB_TYPE_TRANSFER_OUT
return nameDbTypeMap, nil
}
func (c *DataTableTransactionDataImporter) getTransactionDbType(nameDbTypeMap map[string]models.TransactionDbType, transactionTypeName string) (models.TransactionDbType, error) {
transactionType, exists := nameDbTypeMap[transactionTypeName]
if !exists {
return 0, errs.ErrTransactionTypeInvalid
}
return transactionType, nil
}
func (c *DataTableTransactionDataImporter) getTransactionCategoryType(transactionType models.TransactionDbType) (models.TransactionCategoryType, error) {
if transactionType == models.TRANSACTION_DB_TYPE_INCOME {
return models.CATEGORY_TYPE_INCOME, nil
} else if transactionType == models.TRANSACTION_DB_TYPE_EXPENSE {
return models.CATEGORY_TYPE_EXPENSE, nil
} else if transactionType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
return models.CATEGORY_TYPE_TRANSFER, nil
} else {
return 0, errs.ErrTransactionTypeInvalid
}
}
func (c *DataTableTransactionDataImporter) createNewAccountModel(uid int64, accountName string, currency string) *models.Account {
return &models.Account{
Uid: uid,
Name: accountName,
Currency: currency,
}
}
func (c *DataTableTransactionDataImporter) createNewTransactionCategoryModel(uid int64, categoryName string, transactionCategoryType models.TransactionCategoryType) *models.TransactionCategory {
return &models.TransactionCategory{
Uid: uid,
Name: categoryName,
Type: transactionCategoryType,
}
}
func (c *DataTableTransactionDataImporter) createNewTransactionTagModel(uid int64, tagName string) *models.TransactionTag {
return &models.TransactionTag{
Uid: uid,
Name: tagName,
}
}
@@ -0,0 +1,107 @@
package datatable
// ImportedCommonDataTable defines the structure of imported common data table
type ImportedCommonDataTable struct {
innerDataTable ImportedDataTable
dataColumnIndexes map[string]int
}
// ImportedCommonDataRow defines the structure of imported common data row
type ImportedCommonDataRow struct {
rowData map[string]string
}
// ImportedCommonDataRowIterator defines the structure of imported common data row iterator
type ImportedCommonDataRowIterator struct {
commonDataTable *ImportedCommonDataTable
innerIterator ImportedDataRowIterator
}
// HeaderColumnCount returns the total count of column in header row
func (t *ImportedCommonDataTable) HeaderColumnCount() int {
return len(t.innerDataTable.HeaderColumnNames())
}
// HasColumn returns whether the data table has specified column name
func (t *ImportedCommonDataTable) HasColumn(columnName string) bool {
index, exists := t.dataColumnIndexes[columnName]
return exists && index >= 0
}
// DataRowCount returns the total count of common data row
func (t *ImportedCommonDataTable) DataRowCount() int {
return t.innerDataTable.DataRowCount()
}
// DataRowIterator returns the iterator of common data row
func (t *ImportedCommonDataTable) DataRowIterator() CommonDataRowIterator {
return &ImportedCommonDataRowIterator{
commonDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// HasData returns whether the common data row has specified column data
func (r *ImportedCommonDataRow) HasData(columnName string) bool {
_, exists := r.rowData[columnName]
return exists
}
// ColumnCount returns the total count of column in this data row
func (r *ImportedCommonDataRow) ColumnCount() int {
return len(r.rowData)
}
// GetData returns the data in the specified column name
func (r *ImportedCommonDataRow) GetData(columnName string) string {
return r.rowData[columnName]
}
// HasNext returns whether the iterator does not reach the end
func (t *ImportedCommonDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// CurrentRowId returns current row id
func (t *ImportedCommonDataRowIterator) CurrentRowId() string {
return t.innerIterator.CurrentRowId()
}
// Next returns the next common data row
func (t *ImportedCommonDataRowIterator) Next() CommonDataRow {
importedRow := t.innerIterator.Next()
if importedRow == nil {
return nil
}
rowData := make(map[string]string, len(t.commonDataTable.dataColumnIndexes))
for column, columnIndex := range t.commonDataTable.dataColumnIndexes {
if columnIndex < 0 || columnIndex >= importedRow.ColumnCount() {
continue
}
value := importedRow.GetData(columnIndex)
rowData[column] = value
}
return &ImportedCommonDataRow{
rowData: rowData,
}
}
// CreateNewImportedCommonDataTable returns common data table from imported data table
func CreateNewImportedCommonDataTable(dataTable ImportedDataTable) *ImportedCommonDataTable {
headerLineItems := dataTable.HeaderColumnNames()
dataColumnIndexes := make(map[string]int, len(headerLineItems))
for i := 0; i < len(headerLineItems); i++ {
dataColumnIndexes[headerLineItems[i]] = i
}
return &ImportedCommonDataTable{
innerDataTable: dataTable,
dataColumnIndexes: dataColumnIndexes,
}
}
@@ -0,0 +1,34 @@
package datatable
// ImportedDataTable defines the structure of imported data table
type ImportedDataTable interface {
// DataRowCount returns the total count of data row
DataRowCount() int
// HeaderColumnNames returns the header column name list
HeaderColumnNames() []string
// DataRowIterator returns the iterator of data row
DataRowIterator() ImportedDataRowIterator
}
// ImportedDataRow defines the structure of imported data row
type ImportedDataRow interface {
// ColumnCount returns the total count of column in this data row
ColumnCount() int
// GetData returns the data in the specified column index
GetData(columnIndex int) string
}
// ImportedDataRowIterator defines the structure of imported data row iterator
type ImportedDataRowIterator interface {
// HasNext returns whether the iterator does not reach the end
HasNext() bool
// CurrentRowId returns current row id
CurrentRowId() string
// Next returns the next imported data row
Next() ImportedDataRow
}
@@ -0,0 +1,188 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// ImportedTransactionDataTable defines the structure of imported transaction data table
type ImportedTransactionDataTable struct {
innerDataTable ImportedDataTable
dataColumnMapping map[TransactionDataTableColumn]string
dataColumnIndexes map[TransactionDataTableColumn]int
rowParser TransactionDataRowParser
addedColumns map[TransactionDataTableColumn]bool
}
// ImportedTransactionDataRow defines the structure of imported transaction data row
type ImportedTransactionDataRow struct {
transactionDataTable *ImportedTransactionDataTable
rowData map[TransactionDataTableColumn]string
rowDataValid bool
}
// ImportedTransactionDataRowIterator defines the structure of imported transaction data row iterator
type ImportedTransactionDataRowIterator struct {
transactionDataTable *ImportedTransactionDataTable
innerIterator ImportedDataRowIterator
}
// HasColumn returns whether the data table has specified column
func (t *ImportedTransactionDataTable) HasColumn(column TransactionDataTableColumn) bool {
index, exists := t.dataColumnIndexes[column]
if exists && index >= 0 {
return exists
}
if t.addedColumns != nil {
_, exists = t.addedColumns[column]
if exists {
return exists
}
}
return false
}
// TransactionRowCount returns the total count of transaction data row
func (t *ImportedTransactionDataTable) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *ImportedTransactionDataTable) TransactionRowIterator() TransactionDataRowIterator {
return &ImportedTransactionDataRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *ImportedTransactionDataRow) IsValid() bool {
return r.rowDataValid
}
// GetData returns the data in the specified column type
func (r *ImportedTransactionDataRow) GetData(column TransactionDataTableColumn) string {
if !r.rowDataValid {
return ""
}
_, exists := r.transactionDataTable.dataColumnIndexes[column]
if exists {
return r.rowData[column]
}
if r.transactionDataTable.addedColumns != nil {
_, exists = r.transactionDataTable.addedColumns[column]
if exists {
return r.rowData[column]
}
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *ImportedTransactionDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// Next returns the next transaction data row
func (t *ImportedTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error) {
importedRow := t.innerIterator.Next()
if importedRow == nil {
return nil, nil
}
if importedRow.ColumnCount() == 1 && importedRow.GetData(0) == "" {
return &ImportedTransactionDataRow{
transactionDataTable: t.transactionDataTable,
rowData: nil,
rowDataValid: false,
}, nil
}
if importedRow.ColumnCount() < len(t.transactionDataTable.dataColumnIndexes) {
log.Errorf(ctx, "[imported_transaction_data_table.Next] cannot parse data row, because may missing some columns (column count %d in data row is less than header column count %d)", importedRow.ColumnCount(), len(t.transactionDataTable.dataColumnIndexes))
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
rowData := make(map[TransactionDataTableColumn]string, len(t.transactionDataTable.dataColumnIndexes))
rowDataValid := true
for column, columnIndex := range t.transactionDataTable.dataColumnIndexes {
if columnIndex < 0 || columnIndex >= importedRow.ColumnCount() {
continue
}
value := importedRow.GetData(columnIndex)
rowData[column] = value
}
if t.transactionDataTable.rowParser != nil {
rowData, rowDataValid, err = t.transactionDataTable.rowParser.Parse(rowData)
if err != nil {
log.Errorf(ctx, "[imported_transaction_data_table.Next] cannot parse data row, because %s", err.Error())
return nil, err
}
}
return &ImportedTransactionDataRow{
transactionDataTable: t.transactionDataTable,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// CreateNewImportedTransactionDataTable returns transaction data table from imported data table
func CreateNewImportedTransactionDataTable(dataTable ImportedDataTable, dataColumnMapping map[TransactionDataTableColumn]string) *ImportedTransactionDataTable {
return CreateNewImportedTransactionDataTableWithRowParser(dataTable, dataColumnMapping, nil)
}
// CreateNewImportedTransactionDataTableWithRowParser returns transaction data table from imported data table
func CreateNewImportedTransactionDataTableWithRowParser(dataTable ImportedDataTable, dataColumnMapping map[TransactionDataTableColumn]string, rowParser TransactionDataRowParser) *ImportedTransactionDataTable {
headerLineItems := dataTable.HeaderColumnNames()
headerItemMap := make(map[string]int, len(headerLineItems))
for i := 0; i < len(headerLineItems); i++ {
headerItemMap[headerLineItems[i]] = i
}
dataColumnIndexes := make(map[TransactionDataTableColumn]int, len(headerLineItems))
for column, columnName := range dataColumnMapping {
columnIndex, exists := headerItemMap[columnName]
if exists {
dataColumnIndexes[column] = columnIndex
}
}
var addedColumns map[TransactionDataTableColumn]bool
if rowParser != nil {
addedColumnsByParser := rowParser.GetAddedColumns()
addedColumns = make(map[TransactionDataTableColumn]bool, len(addedColumnsByParser))
for i := 0; i < len(addedColumnsByParser); i++ {
addedColumns[addedColumnsByParser[i]] = true
}
}
return &ImportedTransactionDataTable{
innerDataTable: dataTable,
dataColumnMapping: dataColumnMapping,
dataColumnIndexes: dataColumnIndexes,
rowParser: rowParser,
addedColumns: addedColumns,
}
}
@@ -0,0 +1,75 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// TransactionDataTable defines the structure of transaction data table
type TransactionDataTable interface {
// HasColumn returns whether the transaction data table has specified column
HasColumn(column TransactionDataTableColumn) bool
// TransactionRowCount returns the total count of transaction data row
TransactionRowCount() int
// TransactionRowIterator returns the iterator of transaction data row
TransactionRowIterator() TransactionDataRowIterator
}
// TransactionDataRow defines the structure of transaction data row
type TransactionDataRow interface {
// IsValid returns whether this row is valid data for importing
IsValid() bool
// GetData returns the data in the specified column type
GetData(column TransactionDataTableColumn) string
}
// TransactionDataRowIterator defines the structure of transaction data row iterator
type TransactionDataRowIterator interface {
// HasNext returns whether the iterator does not reach the end
HasNext() bool
// Next returns the next transaction data row
Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error)
}
// TransactionDataRowParser defines the structure of transaction data row parser
type TransactionDataRowParser interface {
// GetAddedColumns returns the added columns after converting the data row
GetAddedColumns() []TransactionDataTableColumn
// Parse returns the converted transaction data row
Parse(data map[TransactionDataTableColumn]string) (rowData map[TransactionDataTableColumn]string, rowDataValid bool, err error)
}
// TransactionDataTableBuilder defines the structure of data table builder
type TransactionDataTableBuilder interface {
// AppendTransaction appends the specified transaction to data builder
AppendTransaction(data map[TransactionDataTableColumn]string)
// ReplaceDelimiters returns the text after removing the delimiters
ReplaceDelimiters(text string) string
}
// TransactionDataTableColumn represents the data column type of data table
type TransactionDataTableColumn byte
// Transaction data table columns
const (
TRANSACTION_DATA_TABLE_TRANSACTION_TIME TransactionDataTableColumn = 1
TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE TransactionDataTableColumn = 2
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE TransactionDataTableColumn = 3
TRANSACTION_DATA_TABLE_CATEGORY TransactionDataTableColumn = 4
TRANSACTION_DATA_TABLE_SUB_CATEGORY TransactionDataTableColumn = 5
TRANSACTION_DATA_TABLE_ACCOUNT_NAME TransactionDataTableColumn = 6
TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY TransactionDataTableColumn = 7
TRANSACTION_DATA_TABLE_AMOUNT TransactionDataTableColumn = 8
TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME TransactionDataTableColumn = 9
TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY TransactionDataTableColumn = 10
TRANSACTION_DATA_TABLE_RELATED_AMOUNT TransactionDataTableColumn = 11
TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION TransactionDataTableColumn = 12
TRANSACTION_DATA_TABLE_TAGS TransactionDataTableColumn = 13
TRANSACTION_DATA_TABLE_DESCRIPTION TransactionDataTableColumn = 14
)
@@ -0,0 +1,210 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// WritableTransactionDataTable defines the structure of writable transaction data table
type WritableTransactionDataTable struct {
allData []map[TransactionDataTableColumn]string
supportedColumns map[TransactionDataTableColumn]bool
rowParser TransactionDataRowParser
addedColumns map[TransactionDataTableColumn]bool
}
// WritableTransactionDataRow defines the structure of transaction data row of writable data table
type WritableTransactionDataRow struct {
dataTable *WritableTransactionDataTable
rowData map[TransactionDataTableColumn]string
rowDataValid bool
}
// WritableTransactionDataRowIterator defines the structure of transaction data row iterator of writable data table
type WritableTransactionDataRowIterator struct {
dataTable *WritableTransactionDataTable
nextIndex int
}
// Add appends a new record to data table
func (t *WritableTransactionDataTable) Add(data map[TransactionDataTableColumn]string) {
finalData := make(map[TransactionDataTableColumn]string, len(data))
for column, value := range data {
_, exists := t.supportedColumns[column]
if exists {
finalData[column] = value
}
}
t.allData = append(t.allData, finalData)
}
// Get returns the record in the specified index
func (t *WritableTransactionDataTable) Get(index int) (*WritableTransactionDataRow, error) {
if index >= len(t.allData) {
return nil, nil
}
rowData := t.allData[index]
rowDataValid := true
if t.rowParser != nil {
var err error
rowData, rowDataValid, err = t.rowParser.Parse(rowData)
if err != nil {
return nil, err
}
}
return &WritableTransactionDataRow{
dataTable: t,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// HasColumn returns whether the data table has specified column
func (t *WritableTransactionDataTable) HasColumn(column TransactionDataTableColumn) bool {
_, exists := t.supportedColumns[column]
if exists {
return exists
}
if t.addedColumns != nil {
_, exists = t.addedColumns[column]
if exists {
return exists
}
}
return false
}
// TransactionRowCount returns the total count of transaction data row
func (t *WritableTransactionDataTable) TransactionRowCount() int {
return len(t.allData)
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *WritableTransactionDataTable) TransactionRowIterator() TransactionDataRowIterator {
return &WritableTransactionDataRowIterator{
dataTable: t,
nextIndex: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *WritableTransactionDataRow) ColumnCount() int {
if !r.rowDataValid {
return 0
}
columnCount := 0
for column := range r.rowData {
if r.dataTable.supportedColumns[column] || r.dataTable.addedColumns[column] {
columnCount++
}
}
return columnCount
}
// IsValid returns whether this row is valid data for importing
func (r *WritableTransactionDataRow) IsValid() bool {
return r.rowDataValid
}
// GetData returns the data in the specified column type
func (r *WritableTransactionDataRow) GetData(column TransactionDataTableColumn) string {
if !r.rowDataValid {
return ""
}
_, exists := r.dataTable.supportedColumns[column]
if exists {
return r.rowData[column]
}
if r.dataTable.addedColumns != nil {
_, exists = r.dataTable.addedColumns[column]
if exists {
return r.rowData[column]
}
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *WritableTransactionDataRowIterator) HasNext() bool {
return t.nextIndex < len(t.dataTable.allData)
}
// Next returns the next transaction data row
func (t *WritableTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error) {
if t.nextIndex >= len(t.dataTable.allData) {
return nil, nil
}
rowData := t.dataTable.allData[t.nextIndex]
rowDataValid := true
if t.dataTable.rowParser != nil {
rowData, rowDataValid, err = t.dataTable.rowParser.Parse(rowData)
if err != nil {
log.Errorf(ctx, "[writable_transaction_data_table.Next] cannot parse data row, because %s", err.Error())
return nil, err
}
}
t.nextIndex++
return &WritableTransactionDataRow{
dataTable: t.dataTable,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// CreateNewWritableTransactionDataTable returns a new writable transaction data table according to the specified columns
func CreateNewWritableTransactionDataTable(columns []TransactionDataTableColumn) *WritableTransactionDataTable {
return CreateNewWritableTransactionDataTableWithRowParser(columns, nil)
}
// CreateNewWritableTransactionDataTableWithRowParser returns a new writable transaction data table according to the specified columns
func CreateNewWritableTransactionDataTableWithRowParser(columns []TransactionDataTableColumn, rowParser TransactionDataRowParser) *WritableTransactionDataTable {
supportedColumns := make(map[TransactionDataTableColumn]bool, len(columns))
for i := 0; i < len(columns); i++ {
column := columns[i]
supportedColumns[column] = true
}
var addedColumns map[TransactionDataTableColumn]bool
if rowParser != nil {
addedColumnsByParser := rowParser.GetAddedColumns()
addedColumns = make(map[TransactionDataTableColumn]bool, len(addedColumnsByParser))
for i := 0; i < len(addedColumnsByParser); i++ {
addedColumns[addedColumnsByParser[i]] = true
}
}
return &WritableTransactionDataTable{
allData: make([]map[TransactionDataTableColumn]string, 0),
supportedColumns: supportedColumns,
rowParser: rowParser,
addedColumns: addedColumns,
}
}
@@ -0,0 +1,380 @@
package datatable
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// testDataRowParser defines the structure of test transaction data row parser
type testDataRowParser struct {
}
// GetAddedColumns returns the added columns after converting the data row
func (p *testDataRowParser) GetAddedColumns() []TransactionDataTableColumn {
return []TransactionDataTableColumn{
TRANSACTION_DATA_TABLE_DESCRIPTION,
}
}
// Parse returns the converted transaction data row
func (p *testDataRowParser) Parse(data map[TransactionDataTableColumn]string) (rowData map[TransactionDataTableColumn]string, rowDataValid bool, err error) {
rowData = make(map[TransactionDataTableColumn]string, len(data))
for column, value := range data {
rowData[column] = value
}
if _, exists := rowData[TRANSACTION_DATA_TABLE_SUB_CATEGORY]; exists {
rowData[TRANSACTION_DATA_TABLE_SUB_CATEGORY] = "foo"
} else {
return nil, false, nil
}
rowData[TRANSACTION_DATA_TABLE_TAGS] = "test"
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "bar"
return rowData, true, nil
}
func TestWritableDataTableCreate(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTable(columns)
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_SUB_CATEGORY))
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_ACCOUNT_NAME))
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
assert.False(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE))
assert.False(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY))
}
func TestWritableDataTableAdd(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTable(columns)
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
expectedTransactionTime := "2024-09-01 01:23:45"
expectedTransactionType := "Expense"
expectedSubCategory := "Test Category"
expectedAccountName := "Test Account"
expectedAmount := "123.45"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTransactionTime,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionType,
TRANSACTION_DATA_TABLE_SUB_CATEGORY: expectedSubCategory,
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: expectedAccountName,
TRANSACTION_DATA_TABLE_AMOUNT: expectedAmount,
})
assert.Equal(t, 1, writableDataTable.TransactionRowCount())
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.True(t, dataRow.IsValid())
actualTransactionTime := dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME)
assert.Equal(t, expectedTransactionTime, actualTransactionTime)
actualTransactionType := dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
assert.Equal(t, expectedTransactionType, actualTransactionType)
actualSubCategory := dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, expectedSubCategory, actualSubCategory)
actualAccountName := dataRow.GetData(TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
assert.Equal(t, expectedAccountName, actualAccountName)
actualAmount := dataRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT)
assert.Equal(t, expectedAmount, actualAmount)
}
func TestWritableDataTableAdd_NotExistsColumn(t *testing.T) {
columns := make([]TransactionDataTableColumn, 1)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
writableDataTable := CreateNewWritableTransactionDataTable(columns)
expectedTransactionUnixTime := time.Now().Unix()
expectedTextualTransactionTime := utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTime, time.Local)
expectedTransactionType := "Expense"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTime,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionType,
})
assert.Equal(t, 1, writableDataTable.TransactionRowCount())
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.Equal(t, 1, dataRow.ColumnCount())
}
func TestWritableDataTableGet_NotExistsRow(t *testing.T) {
columns := make([]TransactionDataTableColumn, 1)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
writableDataTable := CreateNewWritableTransactionDataTable(columns)
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.Nil(t, dataRow)
}
func TestWritableDataRowGetData_NotExistsColumn(t *testing.T) {
columns := make([]TransactionDataTableColumn, 1)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
writableDataTable := CreateNewWritableTransactionDataTable(columns)
expectedTransactionUnixTime := time.Now().Unix()
expectedTextualTransactionTime := utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTime, time.Local)
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTime,
})
assert.Equal(t, 1, writableDataTable.TransactionRowCount())
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.Equal(t, 1, dataRow.ColumnCount())
assert.Equal(t, "", dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
}
func TestWritableDataTableDataRowIterator(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTable(columns)
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
expectedTransactionUnixTimes := make([]int64, 3)
expectedTransactionTimes := make([]string, 3)
expectedTransactionTypes := make([]string, 3)
expectedSubCategories := make([]string, 3)
expectedAccountNames := make([]string, 3)
expectedAmounts := make([]string, 3)
expectedTransactionUnixTimes[0] = time.Now().Add(-5 * time.Hour).Unix()
expectedTransactionTimes[0] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[0], time.Local)
expectedTransactionTypes[0] = "Balance Modification"
expectedSubCategories[0] = ""
expectedAccountNames[0] = "Test Account"
expectedAmounts[0] = "123.45"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTransactionTimes[0],
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[0],
TRANSACTION_DATA_TABLE_SUB_CATEGORY: expectedSubCategories[0],
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[0],
TRANSACTION_DATA_TABLE_AMOUNT: expectedAmounts[0],
})
expectedTransactionUnixTimes[1] = time.Now().Add(-45 * time.Minute).Unix()
expectedTransactionTimes[1] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[1], time.Local)
expectedTransactionTypes[1] = "Expense"
expectedSubCategories[1] = "Test Category2"
expectedAccountNames[1] = "Test Account"
expectedAmounts[1] = "-23.4"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTransactionTimes[1],
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[1],
TRANSACTION_DATA_TABLE_SUB_CATEGORY: expectedSubCategories[1],
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[1],
TRANSACTION_DATA_TABLE_AMOUNT: expectedAmounts[1],
})
expectedTransactionUnixTimes[2] = time.Now().Unix()
expectedTransactionTimes[2] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[2], time.Local)
expectedTransactionTypes[2] = "Income"
expectedSubCategories[2] = "Test Category3"
expectedAccountNames[2] = "Test Account2"
expectedAmounts[2] = "123"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTransactionTimes[2],
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[2],
TRANSACTION_DATA_TABLE_SUB_CATEGORY: expectedSubCategories[2],
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[2],
TRANSACTION_DATA_TABLE_AMOUNT: expectedAmounts[2],
})
assert.Equal(t, 3, writableDataTable.TransactionRowCount())
index := 0
iterator := writableDataTable.TransactionRowIterator()
for iterator.HasNext() {
dataRow, err := iterator.Next(core.NewNullContext(), &models.User{})
assert.Nil(t, err)
actualTransactionTime := dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME)
assert.Equal(t, expectedTransactionTimes[index], actualTransactionTime)
actualTransactionType := dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
assert.Equal(t, expectedTransactionTypes[index], actualTransactionType)
actualSubCategory := dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, expectedSubCategories[index], actualSubCategory)
actualAccountName := dataRow.GetData(TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
assert.Equal(t, expectedAccountNames[index], actualAccountName)
actualAmount := dataRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT)
assert.Equal(t, expectedAmounts[index], actualAmount)
index++
}
assert.Equal(t, 3, index)
}
func TestWritableDataTableWithRowParser(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTableWithRowParser(columns, &testDataRowParser{})
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.False(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TAGS))
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "2024-09-01 01:23:45",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Expense",
TRANSACTION_DATA_TABLE_SUB_CATEGORY: "Test Category",
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Test Account",
TRANSACTION_DATA_TABLE_AMOUNT: "123.45",
})
assert.Equal(t, 1, writableDataTable.TransactionRowCount())
// first row
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.True(t, dataRow.IsValid())
assert.Equal(t, 6, dataRow.ColumnCount())
actualSubCategory := dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, "foo", actualSubCategory)
actualTags := dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS)
assert.Equal(t, "", actualTags)
actualDescription := dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
assert.Equal(t, "bar", actualDescription)
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "2024-09-01 12:34:56",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Income",
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Test Account2",
TRANSACTION_DATA_TABLE_AMOUNT: "0.12",
})
assert.Equal(t, 2, writableDataTable.TransactionRowCount())
// second row
dataRow, err = writableDataTable.Get(1)
assert.Nil(t, err)
assert.False(t, dataRow.IsValid())
assert.Equal(t, 0, dataRow.ColumnCount())
actualSubCategory = dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, "", actualSubCategory)
actualTags = dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS)
assert.Equal(t, "", actualTags)
actualDescription = dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
assert.Equal(t, "", actualDescription)
}
func TestWritableDataTableDataRowIteratorWithRowParser(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTableWithRowParser(columns, &testDataRowParser{})
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.False(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TAGS))
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "2024-09-01 01:23:45",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Expense",
TRANSACTION_DATA_TABLE_SUB_CATEGORY: "Test Category",
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Test Account",
TRANSACTION_DATA_TABLE_AMOUNT: "123.45",
})
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "2024-09-01 12:34:56",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Income",
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Test Account2",
TRANSACTION_DATA_TABLE_AMOUNT: "0.12",
})
iterator := writableDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
// first row
dataRow, err := iterator.Next(core.NewNullContext(), &models.User{})
assert.Nil(t, err)
assert.True(t, dataRow.IsValid())
actualSubCategory := dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, "foo", actualSubCategory)
actualTags := dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS)
assert.Equal(t, "", actualTags)
actualDescription := dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
assert.Equal(t, "bar", actualDescription)
assert.True(t, iterator.HasNext())
// second row
dataRow, err = iterator.Next(core.NewNullContext(), &models.User{})
assert.Nil(t, err)
assert.False(t, dataRow.IsValid())
actualSubCategory = dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, "", actualSubCategory)
actualTags = dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS)
assert.Equal(t, "", actualTags)
actualDescription = dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
assert.Equal(t, "", actualDescription)
assert.False(t, iterator.HasNext())
}
@@ -0,0 +1,15 @@
package _default
// defaultTransactionDataCSVFileConverter defines the structure of ezbookkeeping default csv file converter
type defaultTransactionDataCSVFileConverter struct {
defaultTransactionDataPlainTextConverter
}
// Initialize an ezbookkeeping default transaction data csv file converter singleton instance
var (
DefaultTransactionDataCSVFileConverter = &defaultTransactionDataCSVFileConverter{
defaultTransactionDataPlainTextConverter{
columnSeparator: ",",
},
}
)
@@ -0,0 +1,105 @@
package _default
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// defaultTransactionDataPlainTextConverter defines the structure of ezbookkeeping default plain text converter for transaction data
type defaultTransactionDataPlainTextConverter struct {
columnSeparator string
}
const ezbookkeepingLineSeparator = "\n"
const ezbookkeepingGeoLocationSeparator = " "
const ezbookkeepingTagSeparator = ";"
var ezbookkeepingDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "Time",
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE: "Timezone",
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Type",
datatable.TRANSACTION_DATA_TABLE_CATEGORY: "Category",
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: "Sub Category",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Account",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: "Account Currency",
datatable.TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: "Account2",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: "Account2 Currency",
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: "Account2 Amount",
datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION: "Geographic Location",
datatable.TRANSACTION_DATA_TABLE_TAGS: "Tags",
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: "Description",
}
var ezbookkeepingTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: "Balance Modification",
models.TRANSACTION_TYPE_INCOME: "Income",
models.TRANSACTION_TYPE_EXPENSE: "Expense",
models.TRANSACTION_TYPE_TRANSFER: "Transfer",
}
var ezbookkeepingDataColumns = []datatable.TransactionDataTableColumn{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE,
datatable.TRANSACTION_DATA_TABLE_CATEGORY,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY,
datatable.TRANSACTION_DATA_TABLE_AMOUNT,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY,
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT,
datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION,
datatable.TRANSACTION_DATA_TABLE_TAGS,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION,
}
// ToExportedContent returns the exported transaction plain text data
func (c *defaultTransactionDataPlainTextConverter) ToExportedContent(ctx core.Context, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) {
dataTableBuilder := createNewDefaultTransactionPlainTextDataTableBuilder(
len(transactions),
ezbookkeepingDataColumns,
ezbookkeepingDataColumnNameMapping,
c.columnSeparator,
ezbookkeepingLineSeparator,
)
dataTableExporter := datatable.CreateNewExporter(
ezbookkeepingTransactionTypeNameMapping,
ezbookkeepingGeoLocationSeparator,
ezbookkeepingTagSeparator,
)
err := dataTableExporter.BuildExportedContent(ctx, dataTableBuilder, uid, transactions, accountMap, categoryMap, tagMap, allTagIndexes)
if err != nil {
return nil, err
}
return []byte(dataTableBuilder.String()), nil
}
// ParseImportedData returns the imported data by parsing the transaction plain text data
func (c *defaultTransactionDataPlainTextConverter) 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) {
dataTable, err := createNewDefaultPlainTextDataTable(
string(data),
c.columnSeparator,
ezbookkeepingLineSeparator,
)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable := datatable.CreateNewImportedTransactionDataTable(dataTable, ezbookkeepingDataColumnNameMapping)
dataTableImporter := datatable.CreateNewImporter(
ezbookkeepingTransactionTypeNameMapping,
ezbookkeepingGeoLocationSeparator,
ezbookkeepingTagSeparator,
)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,520 @@
package _default
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestDefaultTransactionDataCSVFileConverterToExportedContent(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
transactions := make([]*models.Transaction, 3)
transactions[0] = &models.Transaction{
TransactionId: 1,
TransactionTime: 1725165296000,
Type: models.TRANSACTION_DB_TYPE_INCOME,
TimezoneUtcOffset: 480,
CategoryId: 2,
AccountId: 1,
Amount: 12345,
GeoLongitude: 123.45,
GeoLatitude: 45.67,
Comment: "Hello,World",
}
transactions[1] = &models.Transaction{
TransactionId: 2,
TransactionTime: 1725194096000,
Type: models.TRANSACTION_DB_TYPE_EXPENSE,
TimezoneUtcOffset: 0,
CategoryId: 4,
AccountId: 1,
Amount: -10,
GeoLongitude: 0,
GeoLatitude: 0,
Comment: "Foo#Bar",
}
transactions[2] = &models.Transaction{
TransactionId: 3,
TransactionTime: 1725212096000,
Type: models.TRANSACTION_DB_TYPE_TRANSFER_OUT,
TimezoneUtcOffset: -300,
CategoryId: 6,
AccountId: 1,
Amount: 12345,
RelatedAccountId: 2,
RelatedAccountAmount: 1735,
Comment: "T\te\rs\nt\r\ntest",
}
accountMap := make(map[int64]*models.Account, 2)
accountMap[1] = &models.Account{
AccountId: 1,
Name: "Test Account",
Currency: "CNY",
}
accountMap[2] = &models.Account{
AccountId: 2,
Name: "Test Account2",
Currency: "USD",
}
categoryMap := make(map[int64]*models.TransactionCategory, 6)
categoryMap[1] = &models.TransactionCategory{
CategoryId: 1,
Type: models.CATEGORY_TYPE_INCOME,
Name: "Test Category",
}
categoryMap[2] = &models.TransactionCategory{
CategoryId: 2,
Type: models.CATEGORY_TYPE_INCOME,
ParentCategoryId: 1,
Name: "Test Sub Category",
}
categoryMap[3] = &models.TransactionCategory{
CategoryId: 3,
Type: models.CATEGORY_TYPE_EXPENSE,
Name: "Test Category2",
}
categoryMap[4] = &models.TransactionCategory{
CategoryId: 4,
Type: models.CATEGORY_TYPE_EXPENSE,
ParentCategoryId: 3,
Name: "Test Sub Category2",
}
categoryMap[5] = &models.TransactionCategory{
CategoryId: 5,
Type: models.CATEGORY_TYPE_TRANSFER,
Name: "Test Category3",
}
categoryMap[6] = &models.TransactionCategory{
CategoryId: 6,
Type: models.CATEGORY_TYPE_TRANSFER,
ParentCategoryId: 5,
Name: "Test Sub Category3",
}
tagMap := make(map[int64]*models.TransactionTag, 2)
tagMap[1] = &models.TransactionTag{
TagId: 1,
Name: "Test,Tag",
}
tagMap[2] = &models.TransactionTag{
TagId: 2,
Name: "Test;Tag2",
}
allTagIndexes := make(map[int64][]int64, 2)
allTagIndexes[1] = []int64{1, 2}
allTagIndexes[2] = []int64{3, 1, 4}
allTagIndexes[3] = []int64{2, 3}
expectedContent := "Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n" +
"2024-09-01 12:34:56,+08:00,Income,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,123.450000 45.670000,Test Tag;Test Tag2,Hello World\n" +
"2024-09-01 12:34:56,+00:00,Expense,Test Category2,Test Sub Category2,Test Account,CNY,-0.10,,,,,Test Tag,Foo#Bar\n" +
"2024-09-01 12:34:56,-05:00,Transfer,Test Category3,Test Sub Category3,Test Account,CNY,123.45,Test Account2,USD,17.35,,Test Tag2,T\te s t test\n"
actualContent, err := converter.ToExportedContent(context, 123, transactions, accountMap, categoryMap, tagMap, allTagIndexes)
assert.Nil(t, err)
assert.Equal(t, expectedContent, string(actualContent))
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_MinimumValidData(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 00:00:00,Balance Modification,,Test Account,123.45,,\n"+
"2024-09-01 01:23:45,Income,Test Category,Test Account,0.12,,\n"+
"2024-09-01 12:34:56,Expense,Test Category2,Test Account,1.00,,\n"+
"2024-09-01 23:59:59,Transfer,Test Category3,Test Account,0.05,Test Account2,0.05"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type)
assert.Equal(t, int64(1725153825), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Category", 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(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), 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(1725235199), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "Test Category3", allNewTransactions[3].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), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01T12:34:56,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"09/01/2024 12:34:56,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidType(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Type,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseValidTimezone(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,-10:00,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,+00:00,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,+12:45,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidTimezone(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Asia/Shanghai,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeZoneInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseValidAccountCurrency(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Balance Modification,,Test Account,USD,123.45,,,\n"+
"2024-09-01 12:34:56,Transfer,Test Category2,Test Account,USD,1.23,Test Account2,EUR,1.10"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "USD", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "EUR", allNewAccounts[1].Currency)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidAccountCurrency(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Balance Modification,,Test Account,USD,123.45,,,\n"+
"2024-09-01 12:34:56,Transfer,Test Category3,Test Account,CNY,1.23,Test Account2,EUR,1.10"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Balance Modification,,Test Account,USD,123.45,,,\n"+
"2024-09-01 12:34:56,Transfer,Test Category3,Test Account2,CNY,1.23,Test Account,EUR,1.10"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseNotSupportedCurrency(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Balance Modification,,Test Account,XXX,123.45,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Transfer,Test Category,Test Account,USD,123.45,Test Account2,XXX,123.45"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123 45,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Transfer,Test Category,Test Account,123.45,Test Account2,123 45"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseNoAmount2(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, int64(0), allNewTransactions[0].RelatedAccountAmount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2\n"+
"2024-09-01 12:34:56,Transfer,Test Category,Test Account,123.45,Test Account2"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, int64(12345), allNewTransactions[0].RelatedAccountAmount)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseValidGeographicLocation(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,123.45 45.56"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, 123.45, allNewTransactions[0].GeoLongitude)
assert.Equal(t, 45.56, allNewTransactions[0].GeoLatitude)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidGeographicLocation(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,1"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, float64(0), allNewTransactions[0].GeoLongitude)
assert.Equal(t, float64(0), allNewTransactions[0].GeoLatitude)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,a b"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrGeographicLocationInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,1 "), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrGeographicLocationInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseTag(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, allNewTags, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Tags\n"+
"2024-09-01 00:00:00,Balance Modification,,Test Account,123.45,,,foo;;bar.;#test;hello\tworld;;"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTags[0].Uid)
assert.Equal(t, "foo", allNewTags[0].Name)
assert.Equal(t, int64(1234567890), allNewTags[1].Uid)
assert.Equal(t, "bar.", allNewTags[1].Name)
assert.Equal(t, int64(1234567890), allNewTags[2].Uid)
assert.Equal(t, "#test", allNewTags[2].Name)
assert.Equal(t, int64(1234567890), allNewTags[3].Uid)
assert.Equal(t, "hello\tworld", allNewTags[3].Name)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseDescription(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Description\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,foo bar\t#test"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "foo bar\t#test", allNewTransactions[0].Comment)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_MissingFileHeader(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Column
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"+08:00,Balance Modification,,Test Sub Category,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Sub Category Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Category,Sub Category,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,,Test Sub Category,CNY,123.45,,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,,Test Sub Category,Test Account,CNY,,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account2 Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,,Test Sub Category,Test Account,CNY,123.45,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
@@ -0,0 +1,15 @@
package _default
// defaultTransactionDataTSVFileConverter defines the structure of ezbookkeeping default tsv file converter
type defaultTransactionDataTSVFileConverter struct {
defaultTransactionDataPlainTextConverter
}
// Initialize an ezbookkeeping default transaction data tsv file converter singleton instance
var (
DefaultTransactionDataTSVFileConverter = &defaultTransactionDataTSVFileConverter{
defaultTransactionDataPlainTextConverter{
columnSeparator: "\t",
},
}
)
@@ -0,0 +1,202 @@
package _default
import (
"fmt"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
// defaultPlainTextDataTable defines the structure of ezbookkeeping default plain text data table
type defaultPlainTextDataTable struct {
columnSeparator string
lineSeparator string
allLines []string
headerLineColumnNames []string
}
// defaultPlainTextDataRow defines the structure of ezbookkeeping default plain text data row
type defaultPlainTextDataRow struct {
allItems []string
}
// defaultPlainTextDataRowIterator defines the structure of ezbookkeeping default plain text data row iterator
type defaultPlainTextDataRowIterator struct {
dataTable *defaultPlainTextDataTable
currentIndex int
}
// defaultTransactionPlainTextDataTableBuilder defines the structure of ezbookkeeping default transaction plain text data table builder
type defaultTransactionPlainTextDataTableBuilder struct {
columnSeparator string
lineSeparator string
columns []datatable.TransactionDataTableColumn
dataColumnNameMapping map[datatable.TransactionDataTableColumn]string
dataLineFormat string
builder *strings.Builder
}
// DataRowCount returns the total count of data row
func (t *defaultPlainTextDataTable) DataRowCount() int {
if len(t.allLines) < 1 {
return 0
}
return len(t.allLines) - 1
}
// HeaderColumnNames returns the header column name list
func (t *defaultPlainTextDataTable) HeaderColumnNames() []string {
return t.headerLineColumnNames
}
// DataRowIterator returns the iterator of data row
func (t *defaultPlainTextDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
return &defaultPlainTextDataRowIterator{
dataTable: t,
currentIndex: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *defaultPlainTextDataRow) ColumnCount() int {
return len(r.allItems)
}
// GetData returns the data in the specified column index
func (r *defaultPlainTextDataRow) GetData(columnIndex int) string {
if columnIndex >= len(r.allItems) {
return ""
}
return r.allItems[columnIndex]
}
// HasNext returns whether the iterator does not reach the end
func (t *defaultPlainTextDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allLines)
}
// CurrentRowId returns current index
func (t *defaultPlainTextDataRowIterator) CurrentRowId() string {
return fmt.Sprintf("line#%d", t.currentIndex)
}
// Next returns the next imported data row
func (t *defaultPlainTextDataRowIterator) Next() datatable.ImportedDataRow {
if t.currentIndex+1 >= len(t.dataTable.allLines) {
return nil
}
t.currentIndex++
rowContent := t.dataTable.allLines[t.currentIndex]
rowItems := strings.Split(rowContent, t.dataTable.columnSeparator)
return &defaultPlainTextDataRow{
allItems: rowItems,
}
}
// AppendTransaction appends the specified transaction to data builder
func (b *defaultTransactionPlainTextDataTableBuilder) AppendTransaction(data map[datatable.TransactionDataTableColumn]string) {
dataRowParams := make([]any, len(b.columns))
for i := 0; i < len(b.columns); i++ {
dataRowParams[i] = data[b.columns[i]]
}
b.builder.WriteString(fmt.Sprintf(b.dataLineFormat, dataRowParams...))
}
// ReplaceDelimiters returns the text after removing the delimiters
func (b *defaultTransactionPlainTextDataTableBuilder) ReplaceDelimiters(text string) string {
text = strings.Replace(text, "\r\n", " ", -1)
text = strings.Replace(text, "\r", " ", -1)
text = strings.Replace(text, "\n", " ", -1)
text = strings.Replace(text, b.columnSeparator, " ", -1)
text = strings.Replace(text, b.lineSeparator, " ", -1)
return text
}
// String returns the textual representation of this data
func (b *defaultTransactionPlainTextDataTableBuilder) String() string {
return b.builder.String()
}
func (b *defaultTransactionPlainTextDataTableBuilder) generateHeaderLine() string {
var ret strings.Builder
for i := 0; i < len(b.columns); i++ {
if ret.Len() > 0 {
ret.WriteString(b.columnSeparator)
}
dataColumn := b.columns[i]
columnName := b.dataColumnNameMapping[dataColumn]
ret.WriteString(columnName)
}
ret.WriteString(b.lineSeparator)
return ret.String()
}
func (b *defaultTransactionPlainTextDataTableBuilder) generateDataLineFormat() string {
var ret strings.Builder
for i := 0; i < len(b.columns); i++ {
if ret.Len() > 0 {
ret.WriteString(b.columnSeparator)
}
ret.WriteString("%s")
}
ret.WriteString(b.lineSeparator)
return ret.String()
}
func createNewDefaultPlainTextDataTable(content string, columnSeparator string, lineSeparator string) (*defaultPlainTextDataTable, error) {
allLines := strings.Split(content, lineSeparator)
if len(allLines) < 2 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
headerLine := allLines[0]
headerLine = strings.ReplaceAll(headerLine, "\r", "")
headerLineItems := strings.Split(headerLine, columnSeparator)
return &defaultPlainTextDataTable{
columnSeparator: columnSeparator,
lineSeparator: lineSeparator,
allLines: allLines,
headerLineColumnNames: headerLineItems,
}, nil
}
func createNewDefaultTransactionPlainTextDataTableBuilder(transactionCount int, columns []datatable.TransactionDataTableColumn, dataColumnNameMapping map[datatable.TransactionDataTableColumn]string, columnSeparator string, lineSeparator string) *defaultTransactionPlainTextDataTableBuilder {
var builder strings.Builder
builder.Grow(transactionCount * 100)
dataTableBuilder := &defaultTransactionPlainTextDataTableBuilder{
columnSeparator: columnSeparator,
lineSeparator: lineSeparator,
columns: columns,
dataColumnNameMapping: dataColumnNameMapping,
builder: &builder,
}
headerLine := dataTableBuilder.generateHeaderLine()
dataLineFormat := dataTableBuilder.generateDataLineFormat()
dataTableBuilder.builder.WriteString(headerLine)
dataTableBuilder.dataLineFormat = dataLineFormat
return dataTableBuilder
}
@@ -0,0 +1,191 @@
package excel
import (
"bytes"
"fmt"
"github.com/extrame/xls"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
// ExcelFileImportedDataTable defines the structure of excel file data table
type ExcelFileImportedDataTable struct {
workbook *xls.WorkBook
headerLineColumnNames []string
}
// ExcelFileDataRow defines the structure of excel file data table row
type ExcelFileDataRow struct {
sheet *xls.WorkSheet
rowIndex int
}
// ExcelFileDataRowIterator defines the structure of excel file data table row iterator
type ExcelFileDataRowIterator struct {
dataTable *ExcelFileImportedDataTable
currentSheetIndex int
currentRowIndexInSheet uint16
}
// DataRowCount returns the total count of data row
func (t *ExcelFileImportedDataTable) DataRowCount() int {
totalDataRowCount := 0
for i := 0; i < t.workbook.NumSheets(); i++ {
sheet := t.workbook.GetSheet(i)
if sheet.MaxRow < 1 {
continue
}
totalDataRowCount += int(sheet.MaxRow)
}
return totalDataRowCount
}
// HeaderColumnNames returns the header column name list
func (t *ExcelFileImportedDataTable) HeaderColumnNames() []string {
return t.headerLineColumnNames
}
// DataRowIterator returns the iterator of data row
func (t *ExcelFileImportedDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
return &ExcelFileDataRowIterator{
dataTable: t,
currentSheetIndex: 0,
currentRowIndexInSheet: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *ExcelFileDataRow) ColumnCount() int {
row := r.sheet.Row(r.rowIndex)
return row.LastCol() + 1
}
// GetData returns the data in the specified column index
func (r *ExcelFileDataRow) GetData(columnIndex int) string {
row := r.sheet.Row(r.rowIndex)
return row.Col(columnIndex)
}
// HasNext returns whether the iterator does not reach the end
func (t *ExcelFileDataRowIterator) HasNext() bool {
workbook := t.dataTable.workbook
if t.currentSheetIndex >= workbook.NumSheets() {
return false
}
currentSheet := workbook.GetSheet(t.currentSheetIndex)
if t.currentRowIndexInSheet+1 <= currentSheet.MaxRow {
return true
}
for i := t.currentSheetIndex + 1; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i)
if sheet.MaxRow < 1 {
continue
}
return true
}
return false
}
// CurrentRowId returns current index
func (t *ExcelFileDataRowIterator) CurrentRowId() string {
return fmt.Sprintf("table#%d-row#%d", t.currentSheetIndex, t.currentRowIndexInSheet)
}
// Next returns the next imported data row
func (t *ExcelFileDataRowIterator) Next() datatable.ImportedDataRow {
workbook := t.dataTable.workbook
currentRowIndexInTable := t.currentRowIndexInSheet
for i := t.currentSheetIndex; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i)
if currentRowIndexInTable+1 <= sheet.MaxRow {
t.currentRowIndexInSheet++
currentRowIndexInTable = t.currentRowIndexInSheet
break
}
t.currentSheetIndex++
t.currentRowIndexInSheet = 0
currentRowIndexInTable = 0
}
if t.currentSheetIndex >= workbook.NumSheets() {
return nil
}
currentSheet := workbook.GetSheet(t.currentSheetIndex)
if t.currentRowIndexInSheet > currentSheet.MaxRow {
return nil
}
return &ExcelFileDataRow{
sheet: currentSheet,
rowIndex: int(t.currentRowIndexInSheet),
}
}
// CreateNewExcelFileImportedDataTable returns excel xls data table by file binary data
func CreateNewExcelFileImportedDataTable(data []byte) (*ExcelFileImportedDataTable, error) {
reader := bytes.NewReader(data)
workbook, err := xls.OpenReader(reader, "")
if err != nil {
return nil, err
}
var headerRowItems []string
for i := 0; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i)
if sheet.MaxRow < 0 {
continue
}
row := sheet.Row(0)
if row == nil {
continue
}
if i == 0 {
for j := 0; j <= row.LastCol(); j++ {
headerItem := row.Col(j)
if headerItem == "" {
break
}
headerRowItems = append(headerRowItems, headerItem)
}
} else {
for j := 0; j <= min(row.LastCol(), len(headerRowItems)-1); j++ {
headerItem := row.Col(j)
if headerItem != headerRowItems[j] {
return nil, errs.ErrFieldsInMultiTableAreDifferent
}
}
}
}
return &ExcelFileImportedDataTable{
workbook: workbook,
headerLineColumnNames: headerRowItems,
}, nil
}
@@ -0,0 +1,246 @@
package excel
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestExcelFileImportedDataTableDataRowCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
}
func TestExcelFileImportedDataTableDataRowCount_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 5, datatable.DataRowCount())
}
func TestExcelFileImportedDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestExcelFileImportedDataTableDataRowCount_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestExcelFileImportedDataTableHeaderColumnNames(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
}
func TestExcelFileImportedDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
assert.Nil(t, datatable.HeaderColumnNames())
}
func TestExcelFileDataRowIterator(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
// data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// data row 2
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 3
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelFileDataRowIterator_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
// sheet 1 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 1 data row 2
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 3 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 5 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 5 data row 2
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelFileDataRowIterator_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext())
// not existed data row 1
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 2
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelFileDataRowIterator_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext())
// not existed data row 1
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 2
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelFileDataRowColumnCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.EqualValues(t, 4, row1.ColumnCount())
row2 := iterator.Next()
assert.EqualValues(t, 4, row2.ColumnCount())
}
func TestExcelFileDataRowGetData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
}
func TestExcelFileDataRowGetData_GetNotExistedColumnData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "", row1.GetData(3))
}
func TestExcelFileDataRowGetData_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
sheet1Row1 := iterator.Next()
assert.Equal(t, "1-A2", sheet1Row1.GetData(0))
assert.Equal(t, "1-B2", sheet1Row1.GetData(1))
assert.Equal(t, "1-C2", sheet1Row1.GetData(2))
sheet1Row2 := iterator.Next()
assert.Equal(t, "1-A3", sheet1Row2.GetData(0))
assert.Equal(t, "1-B3", sheet1Row2.GetData(1))
assert.Equal(t, "1-C3", sheet1Row2.GetData(2))
// skip empty sheet2
sheet3Row1 := iterator.Next()
assert.Equal(t, "3-A2", sheet3Row1.GetData(0))
assert.Equal(t, "3-B2", sheet3Row1.GetData(1))
assert.Equal(t, "", sheet3Row1.GetData(2))
// skip no data row sheet4
sheet5Row1 := iterator.Next()
assert.Equal(t, "5-A2", sheet5Row1.GetData(0))
assert.Equal(t, "5-B2", sheet5Row1.GetData(1))
assert.Equal(t, "5-C2", sheet5Row1.GetData(2))
sheet5Row2 := iterator.Next()
assert.Equal(t, "5-A3", sheet5Row2.GetData(0))
assert.Equal(t, "5-B3", sheet5Row2.GetData(1))
assert.Equal(t, "5-C3", sheet5Row2.GetData(2))
}
func TestCreateNewExcelFileImportedDataTable_MultipleSheetsWithDifferentHeaders(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
assert.Nil(t, err)
_, err = CreateNewExcelFileImportedDataTable(testdata)
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
}
-179
View File
@@ -1,179 +0,0 @@
package converters
import (
"fmt"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// EzBookKeepingCSVFileExporter defines the structure of csv file exporter
type EzBookKeepingCSVFileExporter struct {
DataConverter
}
const csvHeaderLine = "Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Tags,Comment\n"
const csvDataLineFormat = "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n"
// ToExportedContent returns the exported csv data
func (e *EzBookKeepingCSVFileExporter) ToExportedContent(uid int64, timezone *time.Location, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexs map[int64][]int64) ([]byte, error) {
var ret strings.Builder
ret.Grow(len(transactions) * 100)
ret.WriteString(csvHeaderLine)
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
continue
}
transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60)
transactionTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone)
transactionTimezone := utils.FormatTimezoneOffset(transactionTimeZone)
transactionType := e.getTransactionTypeName(transaction.Type)
category := e.getTransactionCategoryName(transaction.CategoryId, categoryMap)
subCategory := e.getTransactionSubCategoryName(transaction.CategoryId, categoryMap)
account := e.getAccountName(transaction.AccountId, accountMap)
accountCurrency := e.getAccountCurrency(transaction.AccountId, accountMap)
amount := e.getDisplayAmount(transaction.Amount)
account2 := ""
account2Currency := ""
account2Amount := ""
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2 = e.getAccountName(transaction.RelatedAccountId, accountMap)
account2Currency = e.getAccountCurrency(transaction.RelatedAccountId, accountMap)
account2Amount = e.getDisplayAmount(transaction.RelatedAccountAmount)
}
tags := e.getTags(transaction.TransactionId, allTagIndexs, tagMap)
comment := e.getComment(transaction.Comment)
ret.WriteString(fmt.Sprintf(csvDataLineFormat, transactionTime, transactionTimezone, transactionType, category, subCategory, account, accountCurrency, amount, account2, account2Currency, account2Amount, tags, comment))
}
return []byte(ret.String()), nil
}
func (e *EzBookKeepingCSVFileExporter) getTransactionTypeName(transactionDbType models.TransactionDbType) string {
if transactionDbType == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return "Balance Modification"
} else if transactionDbType == models.TRANSACTION_DB_TYPE_INCOME {
return "Income"
} else if transactionDbType == models.TRANSACTION_DB_TYPE_EXPENSE {
return "Expense"
} else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
return "Transfer"
} else {
return ""
}
}
func (e *EzBookKeepingCSVFileExporter) getTransactionCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if !exists {
return ""
}
if category.ParentCategoryId == 0 {
return category.Name
}
parentCategory, exists := categoryMap[category.ParentCategoryId]
if !exists {
return ""
}
return parentCategory.Name
}
func (e *EzBookKeepingCSVFileExporter) getTransactionSubCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if exists {
return category.Name
} else {
return ""
}
}
func (e *EzBookKeepingCSVFileExporter) getAccountName(accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return account.Name
} else {
return ""
}
}
func (e *EzBookKeepingCSVFileExporter) getAccountCurrency(accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return account.Currency
} else {
return ""
}
}
func (e *EzBookKeepingCSVFileExporter) getDisplayAmount(amount int64) string {
displayAmount := utils.Int64ToString(amount)
integer := utils.SubString(displayAmount, 0, len(displayAmount)-2)
decimals := utils.SubString(displayAmount, -2, 2)
if integer == "" {
integer = "0"
} else if integer == "-" {
integer = "-0"
}
if len(decimals) == 0 {
decimals = "00"
} else if len(decimals) == 1 {
decimals = "0" + decimals
}
return integer + "." + decimals
}
func (e *EzBookKeepingCSVFileExporter) getTags(transactionId int64, allTagIndexs map[int64][]int64, tagMap map[int64]*models.TransactionTag) string {
tagIndexs, exists := allTagIndexs[transactionId]
if !exists {
return ""
}
var ret strings.Builder
for i := 0; i < len(tagIndexs); i++ {
if i > 0 {
ret.WriteString(";")
}
tagIndex := tagIndexs[i]
tag, exists := tagMap[tagIndex]
if !exists {
continue
}
ret.WriteString(tag.Name)
}
return ret.String()
}
func (e *EzBookKeepingCSVFileExporter) getComment(comment string) string {
comment = strings.Replace(comment, ",", " ", -1)
comment = strings.Replace(comment, "\r\n", " ", -1)
comment = strings.Replace(comment, "\n", " ", -1)
return comment
}
@@ -0,0 +1,268 @@
package feidee
import (
"bytes"
"encoding/csv"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"io"
"strings"
csvdatatable "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"
)
const feideeMymoneyAppTransactionDataCsvFileHeader = "随手记导出文件(headers:v5;"
const feideeMymoneyAppTransactionTimeColumnName = "日期"
const feideeMymoneyAppTransactionTypeColumnName = "交易类型"
const feideeMymoneyAppTransactionCategoryColumnName = "类别"
const feideeMymoneyAppTransactionSubCategoryColumnName = "子类别"
const feideeMymoneyAppTransactionAccountNameColumnName = "账户"
const feideeMymoneyAppTransactionAccountCurrencyColumnName = "账户币种"
const feideeMymoneyAppTransactionAmountColumnName = "金额"
const feideeMymoneyAppTransactionDescriptionColumnName = "备注"
const feideeMymoneyAppTransactionRelatedIdColumnName = "关联Id"
const feideeMymoneyAppTransactionTypeModifyBalanceText = "余额变更"
const feideeMymoneyAppTransactionTypeIncomeText = "收入"
const feideeMymoneyAppTransactionTypeExpenseText = "支出"
const feideeMymoneyAppTransactionTypeTransferInText = "转入"
const feideeMymoneyAppTransactionTypeTransferOutText = "转出"
var feideeMymoneyAppDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: feideeMymoneyAppTransactionTimeColumnName,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: feideeMymoneyAppTransactionTypeColumnName,
datatable.TRANSACTION_DATA_TABLE_CATEGORY: feideeMymoneyAppTransactionCategoryColumnName,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: feideeMymoneyAppTransactionSubCategoryColumnName,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: feideeMymoneyAppTransactionAccountNameColumnName,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: feideeMymoneyAppTransactionAccountCurrencyColumnName,
datatable.TRANSACTION_DATA_TABLE_AMOUNT: feideeMymoneyAppTransactionAmountColumnName,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: feideeMymoneyAppTransactionDescriptionColumnName,
}
// feideeMymoneyAppTransactionDataCsvFileImporter defines the structure of feidee mymoney app csv importer for transaction data
type feideeMymoneyAppTransactionDataCsvFileImporter struct{}
// Initialize a feidee mymoney app transaction data csv file importer singleton instance
var (
FeideeMymoneyAppTransactionDataCsvFileImporter = &feideeMymoneyAppTransactionDataCsvFileImporter{}
)
// ParseImportedData returns the imported data by parsing the feidee mymoney app transaction csv data
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) 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) {
fallback := unicode.UTF8.NewDecoder()
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
dataTable, err := c.createNewFeideeMymoneyAppImportedDataTable(ctx, reader)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
commonDataTable := datatable.CreateNewImportedCommonDataTable(dataTable)
if !commonDataTable.HasColumn(feideeMymoneyAppTransactionTimeColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionTypeColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionSubCategoryColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionAccountNameColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionAmountColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionRelatedIdColumnName) {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.ParseImportedData] cannot parse import data, because missing essential columns in header row")
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
transactionDataTable, err := c.createNewFeideeMymoneyAppTransactionDataTable(ctx, commonDataTable)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := datatable.CreateNewSimpleImporter(feideeMymoneyTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppImportedDataTable(ctx core.Context, reader io.Reader) (datatable.ImportedDataTable, error) {
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
allOriginalLines := make([][]string, 0)
hasFileHeader := false
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse feidee mymoney csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if !hasFileHeader {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], feideeMymoneyAppTransactionDataCsvFileHeader) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
continue
}
}
allOriginalLines = append(allOriginalLines, items)
}
if !hasFileHeader {
return nil, errs.ErrInvalidFileHeader
}
if len(allOriginalLines) < 2 {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
dataTable := csvdatatable.CreateNewCustomCsvImportedDataTable(allOriginalLines)
return dataTable, nil
}
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppTransactionDataTable(ctx core.Context, commonDataTable datatable.CommonDataTable) (datatable.TransactionDataTable, error) {
newColumns := make([]datatable.TransactionDataTableColumn, 0, 11)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME)
if commonDataTable.HasColumn(feideeMymoneyAppTransactionCategoryColumnName) {
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_CATEGORY)
}
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
if commonDataTable.HasColumn(feideeMymoneyAppTransactionAccountCurrencyColumnName) {
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY)
}
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_AMOUNT)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME)
if commonDataTable.HasColumn(feideeMymoneyAppTransactionAccountCurrencyColumnName) {
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY)
}
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT)
if commonDataTable.HasColumn(feideeMymoneyAppTransactionDescriptionColumnName) {
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_DESCRIPTION)
}
transactionRowParser := createFeideeMymoneyTransactionDataRowParser()
transactionDataTable := datatable.CreateNewWritableTransactionDataTableWithRowParser(newColumns, transactionRowParser)
transferTransactionsMap := make(map[string]map[datatable.TransactionDataTableColumn]string, 0)
commonDataTableIterator := commonDataTable.DataRowIterator()
for commonDataTableIterator.HasNext() {
dataRow := commonDataTableIterator.Next()
rowId := commonDataTableIterator.CurrentRowId()
if dataRow.ColumnCount() < commonDataTable.HeaderColumnCount() {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", rowId, dataRow.ColumnCount(), commonDataTable.HeaderColumnCount())
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
data := make(map[datatable.TransactionDataTableColumn]string, 11)
for columnType, columnName := range feideeMymoneyAppDataColumnNameMapping {
if dataRow.HasData(columnName) {
data[columnType] = dataRow.GetData(columnName)
}
}
transactionType := data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]
if transactionType == feideeMymoneyAppTransactionTypeModifyBalanceText || transactionType == feideeMymoneyAppTransactionTypeIncomeText || transactionType == feideeMymoneyAppTransactionTypeExpenseText {
if transactionType == feideeMymoneyAppTransactionTypeModifyBalanceText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
} else if transactionType == feideeMymoneyAppTransactionTypeIncomeText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
} else if transactionType == feideeMymoneyAppTransactionTypeExpenseText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
}
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = ""
transactionDataTable.Add(data)
} else if transactionType == feideeMymoneyAppTransactionTypeTransferInText || transactionType == feideeMymoneyAppTransactionTypeTransferOutText {
relatedId := ""
if dataRow.HasData(feideeMymoneyAppTransactionRelatedIdColumnName) {
relatedId = dataRow.GetData(feideeMymoneyAppTransactionRelatedIdColumnName)
}
if relatedId == "" {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] transfer transaction has blank related id in row \"%s\"", rowId)
return nil, errs.ErrRelatedIdCannotBeBlank
}
relatedData, exists := transferTransactionsMap[relatedId]
if !exists {
transferTransactionsMap[relatedId] = data
continue
}
if transactionType == feideeMymoneyAppTransactionTypeTransferInText && relatedData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == feideeMymoneyAppTransactionTypeTransferOutText {
relatedData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
relatedData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
relatedData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
relatedData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = data[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
transactionDataTable.Add(relatedData)
delete(transferTransactionsMap, relatedId)
} else if transactionType == feideeMymoneyAppTransactionTypeTransferOutText && relatedData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == feideeMymoneyAppTransactionTypeTransferInText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = relatedData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = relatedData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
transactionDataTable.Add(data)
delete(transferTransactionsMap, relatedId)
} else {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] transfer transaction type \"%s\" is not expected in row \"%s\"", transactionType, rowId)
return nil, errs.ErrTransactionTypeInvalid
}
} else {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse transaction type \"%s\" in row \"%s\"", transactionType, rowId)
return nil, errs.ErrTransactionTypeInvalid
}
}
if len(transferTransactionsMap) > 0 {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] there are %d transactions (related id is %s) which don't have related records", len(transferTransactionsMap), c.getFeideeMymoneyAppRelatedTransactionIds(transferTransactionsMap))
return nil, errs.ErrFoundRecordNotHasRelatedRecord
}
return transactionDataTable, nil
}
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) getFeideeMymoneyAppRelatedTransactionIds(transferTransactionsMap map[string]map[datatable.TransactionDataTableColumn]string) string {
builder := strings.Builder{}
for relatedId := range transferTransactionsMap {
if builder.Len() > 0 {
builder.WriteRune(',')
}
builder.WriteString(relatedId)
}
return builder.String()
}
@@ -0,0 +1,359 @@
package feidee
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestFeideeMymoneyCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"+
"\"余额变更\",\"2024-09-01 01:00:00\",\"\",\"Test Account2\",\"-0.12\",\"\",\"\"\n"+
"\"收入\",\"2024-09-01 01:23:45\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\"\n"+
"\"支出\",\"2024-09-01 12:34:56\",\"Test Category2\",\"Test Account\",\"1.00\",\"\",\"\"\n"+
"\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account2\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\"\n"+
"\"转出\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account2\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 6, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 2, len(allNewSubExpenseCategories))
assert.Equal(t, 2, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 00:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
assert.Equal(t, "2024-09-01 01:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[2].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[2].Amount)
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Test Category", allNewTransactions[2].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[3].Type)
assert.Equal(t, "2024-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
assert.Equal(t, int64(100), allNewTransactions[3].Amount)
assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Test Category2", 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, "2024-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime), time.UTC))
assert.Equal(t, int64(5), allNewTransactions[4].Amount)
assert.Equal(t, "Test Account", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "Test Category3", 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, "2024-09-02 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime), time.UTC))
assert.Equal(t, int64(50), allNewTransactions[5].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[5].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalDestinationAccountName)
assert.Equal(t, "Test Category3", allNewTransactions[5].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), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[1].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[1].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[1].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[1].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"收入\",\"2024-09-01T12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"收入\",\"09/01/2024 12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"Type\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseValidAccountCurrency(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"USD\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "USD", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "EUR", allNewAccounts[1].Currency)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidAccountCurrency(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"CNY\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"CNY\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseNotSupportedCurrency(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"XXX\",\"123.45\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"USD\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"XXX\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"XXX\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"USD\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"123 45\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123 45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"123 45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"支出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123.45\",\"Test\n"+
"A new line break\",\"\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test\nA new line break", allNewTransactions[0].Comment)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_InvalidRelatedId(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrRelatedIdCannotBeBlank.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转入\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrRelatedIdCannotBeBlank.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrFoundRecordNotHasRelatedRecord.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Column
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"2024-09-01 00:00:00\",\"Test Category\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Sub Category Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Related ID Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
@@ -0,0 +1,77 @@
package feidee
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var feideeMymoneyTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: "余额变更",
models.TRANSACTION_TYPE_INCOME: "收入",
models.TRANSACTION_TYPE_EXPENSE: "支出",
models.TRANSACTION_TYPE_TRANSFER: "转账",
}
// feideeMymoneyTransactionDataRowParser defines the structure of feidee mymoney transaction data row parser
type feideeMymoneyTransactionDataRowParser struct {
}
// GetAddedColumns returns the added columns after converting the data row
func (p *feideeMymoneyTransactionDataRowParser) GetAddedColumns() []datatable.TransactionDataTableColumn {
return nil
}
// Parse returns the converted transaction data row
func (p *feideeMymoneyTransactionDataRowParser) Parse(data map[datatable.TransactionDataTableColumn]string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
rowData = make(map[datatable.TransactionDataTableColumn]string, len(data))
for column, value := range data {
rowData[column] = value
}
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = p.getLongDateTime(rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
}
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] {
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
if err != nil {
return nil, false, errs.ErrAmountInvalid
}
// balance modification transaction in feidee mymoney app is not the opening balance transaction, it can be added many times
if amount >= 0 {
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
} else {
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
}
return rowData, true, nil
}
// Parse returns the converted transaction data row
func (p *feideeMymoneyTransactionDataRowParser) getLongDateTime(str string) string {
if utils.IsValidLongDateTimeFormat(str) {
return str
}
if utils.IsValidLongDateTimeWithoutSecondFormat(str) {
return str + ":00"
}
if utils.IsValidLongDateFormat(str) {
return str + " 00:00:00"
}
return str
}
// createFeideeMymoneyTransactionDataRowParser returns feidee mymoney transaction data row parser
func createFeideeMymoneyTransactionDataRowParser() datatable.TransactionDataRowParser {
return &feideeMymoneyTransactionDataRowParser{}
}
@@ -0,0 +1,44 @@
package feidee
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/converters/excel"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
var feideeMymoneyWebDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "日期",
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "交易类型",
datatable.TRANSACTION_DATA_TABLE_CATEGORY: "分类",
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: "子分类",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "账户1",
datatable.TRANSACTION_DATA_TABLE_AMOUNT: "金额",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: "账户2",
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: "备注",
}
// feideeMymoneyWebTransactionDataXlsFileImporter defines the structure of feidee mymoney (web) xls importer for transaction data
type feideeMymoneyWebTransactionDataXlsFileImporter struct {
datatable.DataTableTransactionDataImporter
}
// Initialize a feidee mymoney (web) transaction data xls file importer singleton instance
var (
FeideeMymoneyWebTransactionDataXlsFileImporter = &feideeMymoneyWebTransactionDataXlsFileImporter{}
)
// ParseImportedData returns the imported data by parsing the feidee mymoney (web) transaction xls data
func (c *feideeMymoneyWebTransactionDataXlsFileImporter) 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) {
dataTable, err := excel.CreateNewExcelFileImportedDataTable(data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionRowParser := createFeideeMymoneyTransactionDataRowParser()
transactionDataTable := datatable.CreateNewImportedTransactionDataTableWithRowParser(dataTable, feideeMymoneyWebDataColumnNameMapping, transactionRowParser)
dataTableImporter := datatable.CreateNewSimpleImporter(feideeMymoneyTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,117 @@
package feidee
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestFeideeMymoneyTransactionDataXlsImporterParseImportedData_MinimumValidData(t *testing.T) {
converter := FeideeMymoneyWebTransactionDataXlsFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
testdata, err := os.ReadFile("../../../testdata/feidee_mymoney_test_file.xls")
assert.Nil(t, err)
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, testdata, 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 7, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 3, len(allNewSubExpenseCategories))
assert.Equal(t, 3, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 00:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
assert.Equal(t, "2024-09-01 01:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[2].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[2].Amount)
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Test Category", allNewTransactions[2].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[3].Type)
assert.Equal(t, "2024-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
assert.Equal(t, int64(100), allNewTransactions[3].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Test Category2", 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, "2024-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime), time.UTC))
assert.Equal(t, int64(5), allNewTransactions[4].Amount)
assert.Equal(t, "Test Comment5", allNewTransactions[4].Comment)
assert.Equal(t, "Test Account", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "Test Category3", allNewTransactions[4].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[5].Type)
assert.Equal(t, "2024-09-10 00:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime), time.UTC))
assert.Equal(t, int64(-54300), allNewTransactions[5].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[5].OriginalSourceAccountName)
assert.Equal(t, "Test Category5", allNewTransactions[5].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[6].Type)
assert.Equal(t, "2024-09-11 05:06:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[6].TransactionTime), time.UTC))
assert.Equal(t, int64(-12340), allNewTransactions[6].Amount)
assert.Equal(t, "Line1\nLine2", allNewTransactions[6].Comment)
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
assert.Equal(t, "Test Category4", allNewTransactions[6].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), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[1].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[1].Name)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[2].Uid)
assert.Equal(t, "Test Category4", allNewSubExpenseCategories[2].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[1].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[1].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[2].Uid)
assert.Equal(t, "Test Category5", allNewSubIncomeCategories[2].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
}
@@ -0,0 +1,55 @@
package fireflyIII
import (
"bytes"
"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/models"
)
var fireflyIIITransactionDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "date",
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "type",
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: "category",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "source_name",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: "currency_code",
datatable.TRANSACTION_DATA_TABLE_AMOUNT: "amount",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: "destination_name",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: "foreign_currency_code",
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: "foreign_amount",
datatable.TRANSACTION_DATA_TABLE_TAGS: "tags",
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: "description",
}
var fireflyIIITransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: "Opening balance",
models.TRANSACTION_TYPE_INCOME: "Deposit",
models.TRANSACTION_TYPE_EXPENSE: "Withdrawal",
models.TRANSACTION_TYPE_TRANSFER: "Transfer",
}
// fireflyIIITransactionDataCsvFileImporter defines the structure of firefly III csv importer for transaction data
type fireflyIIITransactionDataCsvFileImporter struct{}
// Initialize a firefly III transaction data csv file importer singleton instance
var (
FireflyIIITransactionDataCsvFileImporter = &fireflyIIITransactionDataCsvFileImporter{}
)
// ParseImportedData returns the imported data by parsing the firefly III transaction csv data
func (c *fireflyIIITransactionDataCsvFileImporter) 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) {
reader := bytes.NewReader(data)
dataTable, err := csv.CreateNewCsvImportedDataTable(ctx, reader)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionRowParser := createFireflyIIITransactionDataRowParser()
transactionDataTable := datatable.CreateNewImportedTransactionDataTableWithRowParser(dataTable, fireflyIIITransactionDataColumnNameMapping, transactionRowParser)
dataTableImporter := datatable.CreateNewImporter(fireflyIIITransactionTypeNameMapping, "", ",")
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,296 @@
package fireflyIII
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestFireFlyIIICsvFileConverterParseImportedData_MinimumValidData(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"\"Opening balance\",-123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
"Deposit,-0.12,2024-09-01T01:23:45+08:00,\"A revenue account\",\"Test Account\",\"Test Category\"\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category2\"\n"+
"Transfer,-0.05,2024-09-01T23:59:59+08:00,\"Test Account\",\"Test Account2\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725120000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type)
assert.Equal(t, int64(1725125025), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Category", 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(1725165296), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), 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(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "Test Category3", allNewTransactions[3].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), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01 12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidType(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Type,-123.45,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTimezone(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56-10:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56+00:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56+12:45,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidAccountCurrency(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"\"Opening balance\",-123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
"Transfer,-1.23,-1.10,2024-09-01T23:59:59+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "USD", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "EUR", allNewAccounts[1].Currency)
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidAccountCurrency(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"\"Opening balance\",-123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
"Transfer,-1.23,-1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account\",\"Test Account2\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"\"Opening balance\",-123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
"Transfer,-1.23,-1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account2\",\"Test Account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseNotSupportedCurrency(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"\"Opening balance\",-123.45,,2024-09-01T00:00:00+08:00,XXX,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"Transfer,-123.45,-123.45,2024-09-01T23:59:59+08:00,USD,XXX,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-123 45,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,source_name,destination_name,category\n"+
"Transfer,-123.45,-123 45,2024-09-01T23:59:59+08:00,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseDescription(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,description,date,source_name,destination_name,category\n"+
"Withdrawal,-123.45,\"foo bar\t#test\",2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "foo bar\t#test", allNewTransactions[0].Comment)
}
func TestFireFlyIIICsvFileConverterParseImportedData_MissingFileHeader(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Column
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,source_name,destination_name,category\n"+
"\"Opening balance\",-123.45,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("amount,date,source_name,destination_name,category\n"+
"-123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Sub Category Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name\n"+
"\"Opening balance\",-123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,destination_name,category\n"+
"\"Opening balance\",-123.45,2024-09-01T00:00:00+08:00,\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,date,source_name,destination_name,category\n"+
"\"Opening balance\",2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account2 Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,category\n"+
"\"Opening balance\",-123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
@@ -0,0 +1,95 @@
package fireflyIII
import (
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// fireflyIIITransactionDataRowParser defines the structure of firefly III transaction data row parser
type fireflyIIITransactionDataRowParser struct {
}
// GetAddedColumns returns the added columns after converting the data row
func (p *fireflyIIITransactionDataRowParser) GetAddedColumns() []datatable.TransactionDataTableColumn {
return []datatable.TransactionDataTableColumn{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
}
}
// Parse returns the converted transaction data row
func (p *fireflyIIITransactionDataRowParser) Parse(data map[datatable.TransactionDataTableColumn]string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
rowData = make(map[datatable.TransactionDataTableColumn]string, len(data))
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
for column, value := range data {
rowData[column] = value
}
// parse long date time and timezone
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
if strings.Index(rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME], "T") <= 0 {
return nil, false, errs.ErrTransactionTimeInvalid
}
dateTime, err := utils.ParseFromLongDateTimeWithTimezone(strings.ReplaceAll(rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME], "T", " "))
if err != nil {
return nil, false, errs.ErrTransactionTimeInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
}
// 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 {
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 {
return nil, false, errs.ErrAmountInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-amount)
} else {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
}
// the related account currency field is foreign currency in firefly III actually
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] == "" {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
}
// the destination account of modify balance transaction in firefly III is the asset account
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] {
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
}
// the destination account of income transaction in firefly III is the asset account
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
}
return rowData, true, nil
}
// createFireflyIIITransactionDataRowParser returns firefly III transaction data row parser
func createFireflyIIITransactionDataRowParser() datatable.TransactionDataRowParser {
return &fireflyIIITransactionDataRowParser{}
}
+87
View File
@@ -0,0 +1,87 @@
package gnucash
import "encoding/xml"
const gnucashCommodityCurrencySpace = "CURRENCY"
const gnucashRootAccountType = "ROOT"
const gnucashEquityAccountType = "EQUITY"
const gnucashIncomeAccountType = "INCOME"
const gnucashExpenseAccountType = "EXPENSE"
const gnucashSlotEquityType = "equity-type"
const gnucashSlotEquityTypeOpeningBalance = "opening-balance"
var gnucashAssetOrLiabilityAccountTypes = map[string]bool{
"ASSET": true,
"BANK": true,
"CASH": true,
"CREDIT": true,
"LIABILITY": true,
"MUTUAL": true,
"PAYABLE": true,
"RECEIVABLE": true,
"STOCK": true,
}
// gnucashDatabase represents the struct of gnucash database file
type gnucashDatabase struct {
XMLName xml.Name `xml:"gnc-v2"`
Counts []*gnucashCountData `xml:"count-data"`
Books []*gnucashBookData `xml:"book"`
}
// gnucashCountData represents the struct of gnucash count data
type gnucashCountData struct {
Key string `xml:"type,attr"`
Value string `xml:",chardata"`
}
// gnucashBookData represents the struct of gnucash book data
type gnucashBookData struct {
Id string `xml:"id"`
Counts []*gnucashCountData `xml:"count-data"`
Accounts []*gnucashAccountData `xml:"account"`
Transactions []*gnucashTransactionData `xml:"transaction"`
}
// gnucashCommodityData represents the struct of gnucash commodity data
type gnucashCommodityData struct {
Space string `xml:"space"`
Id string `xml:"id"`
}
// gnucashSlotData represents the struct of gnucash slot data
type gnucashSlotData struct {
Key string `xml:"key"`
Value string `xml:"value"`
}
// gnucashAccountData represents the struct of gnucash account data
type gnucashAccountData struct {
Name string `xml:"name"`
Id string `xml:"id"`
AccountType string `xml:"type"`
Description string `xml:"description"`
ParentId string `xml:"parent"`
Commodity *gnucashCommodityData `xml:"commodity"`
Slots []*gnucashSlotData `xml:"slots>slot"`
}
// gnucashTransactionData represents the struct of gnucash transaction data
type gnucashTransactionData struct {
Id string `xml:"id"`
Currency *gnucashCommodityData `xml:"currency"`
PostedDate string `xml:"date-posted>date"`
EnteredDate string `xml:"date-entered>date"`
Description string `xml:"description"`
Splits []*gnucashTransactionSplitData `xml:"splits>split"`
}
// gnucashTransactionSplitData represents the struct of gnucash transaction split data
type gnucashTransactionSplitData struct {
Id string `xml:"id"`
ReconciledState string `xml:"reconciled-state"`
Value string `xml:"value"`
Quantity string `xml:"quantity"`
Account string `xml:"account"`
}
@@ -0,0 +1,56 @@
package gnucash
import (
"bytes"
"compress/gzip"
"encoding/xml"
"golang.org/x/net/html/charset"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
// gnucashDatabaseReader defines the structure of gnucash database reader
type gnucashDatabaseReader struct {
xmlDecoder *xml.Decoder
}
// read returns the imported gnucash data
func (r *gnucashDatabaseReader) read(ctx core.Context) (*gnucashDatabase, error) {
database := &gnucashDatabase{}
err := r.xmlDecoder.Decode(&database)
if err != nil {
return nil, err
}
return database, nil
}
func createNewGnuCashDatabaseReader(data []byte) (*gnucashDatabaseReader, error) {
if len(data) > 2 && data[0] == 0x1F && data[1] == 0x8B { // gzip magic number
gzipReader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
xmlDecoder := xml.NewDecoder(gzipReader)
xmlDecoder.CharsetReader = charset.NewReaderLabel
return &gnucashDatabaseReader{
xmlDecoder: xmlDecoder,
}, nil
} else if len(data) > 5 && data[0] == 0x3C && data[1] == 0x3F && data[2] == 0x78 && data[3] == 0x6D && data[4] == 0x6C { // <?xml
xmlDecoder := xml.NewDecoder(bytes.NewReader(data))
xmlDecoder.CharsetReader = charset.NewReaderLabel
return &gnucashDatabaseReader{
xmlDecoder: xmlDecoder,
}, nil
}
return nil, errs.ErrInvalidGnuCashFile
}
@@ -0,0 +1,49 @@
package gnucash
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var gnucashTransactionTypeNameMapping = 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)),
}
// gnucashTransactionDataImporter defines the structure of gnucash importer for transaction data
type gnucashTransactionDataImporter struct {
}
// Initialize a gnucash transaction data importer singleton instance
var (
GnuCashTransactionDataImporter = &gnucashTransactionDataImporter{}
)
// ParseImportedData returns the imported data by parsing the gnucash transaction data
func (c *gnucashTransactionDataImporter) 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) {
gnucashDataReader, err := createNewGnuCashDatabaseReader(data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
gnucashData, err := gnucashDataReader.read(ctx)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable, err := createNewGnuCashTransactionDataTable(gnucashData)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := datatable.CreateNewSimpleImporter(gnucashTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
package gnucash
import (
"strings"
"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 gnucashTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE: true,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
datatable.TRANSACTION_DATA_TABLE_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true,
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
}
// gnucashTransactionDataTable defines the structure of gnucash transaction data table
type gnucashTransactionDataTable struct {
allData []*gnucashTransactionData
accountMap map[string]*gnucashAccountData
}
// gnucashTransactionDataRow defines the structure of gnucash transaction data row
type gnucashTransactionDataRow struct {
dataTable *gnucashTransactionDataTable
data *gnucashTransactionData
finalItems map[datatable.TransactionDataTableColumn]string
isValid bool
}
// gnucashTransactionDataRowIterator defines the structure of gnucash transaction data row iterator
type gnucashTransactionDataRowIterator struct {
dataTable *gnucashTransactionDataTable
currentIndex int
}
// HasColumn returns whether the transaction data table has specified column
func (t *gnucashTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
_, exists := gnucashTransactionSupportedColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *gnucashTransactionDataTable) TransactionRowCount() int {
return len(t.allData)
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *gnucashTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &gnucashTransactionDataRowIterator{
dataTable: t,
currentIndex: -1,
}
}
// IsValid returns whether this row is valid data for importing
func (r *gnucashTransactionDataRow) IsValid() bool {
return r.isValid
}
// GetData returns the data in the specified column type
func (r *gnucashTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
_, exists := gnucashTransactionSupportedColumns[column]
if exists {
return r.finalItems[column]
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *gnucashTransactionDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allData)
}
// Next returns the next imported data row
func (t *gnucashTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
if t.currentIndex+1 >= len(t.dataTable.allData) {
return nil, nil
}
t.currentIndex++
data := t.dataTable.allData[t.currentIndex]
rowItems, isValid, err := t.parseTransaction(ctx, user, data)
if err != nil {
return nil, err
}
return &gnucashTransactionDataRow{
dataTable: t.dataTable,
data: data,
finalItems: rowItems,
isValid: isValid,
}, nil
}
func (t *gnucashTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, gnucashTransaction *gnucashTransactionData) (map[datatable.TransactionDataTableColumn]string, bool, error) {
data := make(map[datatable.TransactionDataTableColumn]string, len(gnucashTransactionSupportedColumns))
if gnucashTransaction.PostedDate == "" {
return nil, false, errs.ErrMissingTransactionTime
}
dateTime, err := utils.ParseFromLongDateTimeWithTimezone2(gnucashTransaction.PostedDate)
if err != nil {
return nil, false, errs.ErrTransactionTimeInvalid
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
if len(gnucashTransaction.Splits) == 2 {
splitData1 := gnucashTransaction.Splits[0]
splitData2 := gnucashTransaction.Splits[1]
account1 := t.dataTable.accountMap[splitData1.Account]
account2 := t.dataTable.accountMap[splitData2.Account]
if account1 == nil || account2 == nil {
return nil, false, errs.ErrMissingAccountData
}
if splitData1.Quantity == "" || splitData2.Quantity == "" {
return nil, false, errs.ErrAmountInvalid
}
amount1, err := t.parseAmount(splitData1.Quantity)
if err != nil {
return nil, false, err
}
amount2, err := t.parseAmount(splitData2.Quantity)
if err != nil {
return nil, false, err
}
if ((account1.AccountType == gnucashEquityAccountType || account1.AccountType == gnucashIncomeAccountType) && gnucashAssetOrLiabilityAccountTypes[account2.AccountType]) ||
((account2.AccountType == gnucashEquityAccountType || account2.AccountType == gnucashIncomeAccountType) && gnucashAssetOrLiabilityAccountTypes[account1.AccountType]) { // income
fromAccount := account1
toAccount := account2
toAmount := amount2
if (account2.AccountType == gnucashEquityAccountType || account2.AccountType == gnucashIncomeAccountType) && gnucashAssetOrLiabilityAccountTypes[account1.AccountType] {
fromAccount = account2
toAccount = account1
toAmount = amount1
}
if t.hasSpecifiedSlotKeyValue(fromAccount.Slots, gnucashSlotEquityType, gnucashSlotEquityTypeOpeningBalance) {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE))
} else {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME))
}
data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = t.getCategoryName(fromAccount)
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = fromAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = toAccount.Name
if toAccount.Commodity != nil && toAccount.Commodity.Space == gnucashCommodityCurrencySpace {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = toAccount.Commodity.Id
}
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = toAmount
} else if (account1.AccountType == gnucashExpenseAccountType && gnucashAssetOrLiabilityAccountTypes[account2.AccountType]) ||
(account2.AccountType == gnucashExpenseAccountType && gnucashAssetOrLiabilityAccountTypes[account1.AccountType]) { // expense
fromAccount := account1
fromAmount := amount1
toAccount := account2
if account1.AccountType == gnucashExpenseAccountType && gnucashAssetOrLiabilityAccountTypes[account2.AccountType] {
fromAccount = account2
fromAmount = amount2
toAccount = account1
}
amount, err := utils.ParseAmount(fromAmount)
if err != nil {
return nil, false, errs.ErrAmountInvalid
}
fromAmount = utils.FormatAmount(-amount)
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE))
data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = t.getCategoryName(toAccount)
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = toAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name
if fromAccount.Commodity != nil && fromAccount.Commodity.Space == gnucashCommodityCurrencySpace {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromAccount.Commodity.Id
}
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = fromAmount
} else if gnucashAssetOrLiabilityAccountTypes[account1.AccountType] && gnucashAssetOrLiabilityAccountTypes[account2.AccountType] {
var fromAccount, toAccount *gnucashAccountData
var fromAmount, toAmount string
if len(amount1) > 0 && amount1[0] == '-' {
fromAccount = account1
fromAmount = amount1[1:]
toAccount = account2
toAmount = amount2
} else if len(amount2) > 0 && amount2[0] == '-' {
fromAccount = account2
fromAmount = amount2[1:]
toAccount = account1
toAmount = amount1
} else {
log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse transfer transaction \"id:%s\", because unexcepted account amounts \"%s\" and \"%s\"", gnucashTransaction.Id, amount1, amount2)
return nil, false, errs.ErrInvalidGnuCashFile
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER))
data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = ""
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name
if fromAccount.Commodity != nil && fromAccount.Commodity.Space == gnucashCommodityCurrencySpace {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromAccount.Commodity.Id
}
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = fromAmount
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = toAccount.Name
if toAccount.Commodity != nil && toAccount.Commodity.Space == gnucashCommodityCurrencySpace {
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = toAccount.Commodity.Id
}
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = toAmount
} else {
log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse transaction \"id:%s\", because unexcepted account types \"%s\" and \"%s\"", gnucashTransaction.Id, account1.AccountType, account2.AccountType)
return nil, false, errs.ErrThereAreNotSupportedTransactionType
}
} else if len(gnucashTransaction.Splits) == 1 {
splitData := gnucashTransaction.Splits[0]
account := t.dataTable.accountMap[splitData.Account]
if account == nil {
return nil, false, errs.ErrMissingAccountData
}
if splitData.Quantity == "" {
return nil, false, errs.ErrAmountInvalid
}
amount, err := t.parseAmount(splitData.Quantity)
if err != nil {
return nil, false, err
}
amountNum, err := utils.ParseAmount(amount)
if err != nil {
return nil, false, err
}
if amountNum == 0 {
log.Warnf(ctx, "[gnucash_transaction_table.parseTransaction] skip parsing transaction \"id:%s\" with zero amount", gnucashTransaction.Id)
return nil, false, nil
}
log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse transaction \"id:%s\", because split count is %d", gnucashTransaction.Id, len(gnucashTransaction.Splits))
return nil, false, errs.ErrThereAreNotSupportedTransactionType
} else if len(gnucashTransaction.Splits) < 1 {
log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse transaction \"id:%s\", because split count is %d", gnucashTransaction.Id, len(gnucashTransaction.Splits))
return nil, false, errs.ErrInvalidGnuCashFile
} else {
log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse split transaction \"id:%s\", because split count is %d", gnucashTransaction.Id, len(gnucashTransaction.Splits))
return nil, false, errs.ErrNotSupportedSplitTransactions
}
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = gnucashTransaction.Description
return data, true, nil
}
func (t *gnucashTransactionDataRowIterator) parseAmount(quantity string) (string, error) {
items := strings.Split(quantity, "/")
if len(items) != 2 {
return "", errs.ErrAmountInvalid
}
value, err := utils.StringToInt64(items[0])
if err != nil {
return "", errs.ErrAmountInvalid
}
if items[1] == "100" {
return utils.FormatAmount(value), nil
}
factor, err := utils.StringToInt64(items[1])
if err != nil {
return "", errs.ErrAmountInvalid
}
value = value * 100 / factor
return utils.FormatAmount(value), nil
}
func (t *gnucashTransactionDataRowIterator) getCategoryName(accountData *gnucashAccountData) string {
if accountData == nil || accountData.ParentId == "" {
return ""
}
parentAccount := t.dataTable.accountMap[accountData.ParentId]
if parentAccount == nil || parentAccount.AccountType == gnucashRootAccountType {
return ""
}
return parentAccount.Name
}
func (t *gnucashTransactionDataRowIterator) hasSpecifiedSlotKeyValue(slots []*gnucashSlotData, key string, value string) bool {
for i := 0; i < len(slots); i++ {
if slots[i].Key == key && slots[i].Value == value {
return true
}
}
return false
}
func createNewGnuCashTransactionDataTable(database *gnucashDatabase) (*gnucashTransactionDataTable, error) {
if database == nil || len(database.Books) < 1 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
allData := make([]*gnucashTransactionData, 0)
accountMap := make(map[string]*gnucashAccountData)
for i := 0; i < len(database.Books); i++ {
book := database.Books[i]
allData = append(allData, book.Transactions...)
for j := 0; j < len(book.Accounts); j++ {
account := book.Accounts[j]
accountMap[account.Id] = account
}
}
return &gnucashTransactionDataTable{
allData: allData,
accountMap: accountMap,
}, nil
}
+58
View File
@@ -0,0 +1,58 @@
package iif
// iifAccountDataset defines the structure of intuit interchange format (iif) account dataset
type iifAccountDataset struct {
accountDataColumnIndexes map[string]int
accounts []*iifAccountData
}
// iifAccountData defines the structure of intuit interchange format (iif) account data
type iifAccountData struct {
dataItems []string
}
// iifTransactionDataset defines the structure of intuit interchange format (iif) transaction dataset
type iifTransactionDataset struct {
transactionDataColumnIndexes map[string]int
splitDataColumnIndexes map[string]int
transactions []*iifTransactionData
}
// iifTransactionData defines the structure of intuit interchange format (iif) transaction data
type iifTransactionData struct {
dataItems []string
splitData []*iifTransactionSplitData
}
// iifTransactionSplitData defines the structure of intuit interchange format (iif) transaction split data
type iifTransactionSplitData struct {
dataItems []string
}
func (s *iifTransactionDataset) getTransactionDataItemValue(transactionData *iifTransactionData, columnName string) (string, bool) {
if transactionData == nil {
return "", false
}
index, exists := s.transactionDataColumnIndexes[columnName]
if !exists || index < 0 || index >= len(transactionData.dataItems) {
return "", false
}
return transactionData.dataItems[index], true
}
func (s *iifTransactionDataset) getSplitDataItemValue(splitData *iifTransactionSplitData, columnName string) (string, bool) {
if splitData == nil {
return "", false
}
index, exists := s.splitDataColumnIndexes[columnName]
if !exists || index < 0 || index >= len(splitData.dataItems) {
return "", false
}
return splitData.dataItems[index], true
}
+237
View File
@@ -0,0 +1,237 @@
package iif
import (
"bytes"
"encoding/csv"
"io"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
)
const iifAccountSampleLineSignColumnName = "!ACCNT"
const iifTransactionSampleLineSignColumnName = "!TRNS"
const iifTransactionSplitSampleLineSignColumnName = "!SPL"
const iifTransactionEndSampleLineSignColumnName = "!ENDTRNS"
const iifAccountLineSignColumnName = "ACCNT"
const iifTransactionLineSignColumnName = "TRNS"
const iifTransactionSplitLineSignColumnName = "SPL"
const iifTransactionEndLineSignColumnName = "ENDTRNS"
// iifDataReader defines the structure of intuit interchange format (iif) data reader
type iifDataReader struct {
reader *csv.Reader
}
// read returns the iif transaction dataset
func (r *iifDataReader) read(ctx core.Context) ([]*iifAccountDataset, []*iifTransactionDataset, error) {
allAccountDatasets := make([]*iifAccountDataset, 0)
allTransactionDatasets := make([]*iifTransactionDataset, 0)
currentDatasetType := ""
lastLineSign := ""
var currentAccountDataset *iifAccountDataset
var currentTransactionDataset *iifTransactionDataset
var currentTransactionData *iifTransactionData
for {
items, err := r.reader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[iif_data_reader.read] cannot parse tsv data, because %s", err.Error())
return nil, nil, errs.ErrInvalidIIFFile
}
if len(items) == 1 && items[0] == "" {
continue
}
if len(items[0]) < 1 {
log.Errorf(ctx, "[iif_data_reader.read] line first column is empty")
return nil, nil, errs.ErrInvalidIIFFile
}
// read sample line
if items[0][0] == '!' {
if lastLineSign != "" {
log.Errorf(ctx, "[iif_data_reader.read] iif missing transaction end line")
return nil, nil, errs.ErrInvalidIIFFile
}
if currentAccountDataset != nil {
allAccountDatasets = append(allAccountDatasets, currentAccountDataset)
currentAccountDataset = nil
}
if currentTransactionDataset != nil {
allTransactionDatasets = append(allTransactionDatasets, currentTransactionDataset)
currentTransactionDataset = nil
}
if items[0] == iifTransactionSplitSampleLineSignColumnName || items[0] == iifTransactionEndSampleLineSignColumnName {
log.Errorf(ctx, "[iif_data_reader.read] read transaction split sample line or transaction end sample line sign before transaction sample line sign")
return nil, nil, errs.ErrInvalidIIFFile
} else {
currentDatasetType = items[0]
lastLineSign = ""
}
if currentDatasetType == iifAccountSampleLineSignColumnName {
currentAccountDataset, err = r.readAccountSampleLine(ctx, items)
if err != nil {
return nil, nil, err
}
} else if currentDatasetType == iifTransactionSampleLineSignColumnName {
currentTransactionDataset, err = r.readTransactionSampleLines(ctx, items)
if err != nil {
return nil, nil, err
}
} // not process (read sample line) for other dataset type
continue
}
// read data lines
if currentDatasetType == "" {
log.Errorf(ctx, "[iif_data_reader.read] cannot read data line before sample line")
return nil, nil, errs.ErrInvalidIIFFile
} else if currentDatasetType == iifAccountSampleLineSignColumnName && currentAccountDataset != nil {
if items[0] == iifAccountLineSignColumnName {
accountData := &iifAccountData{
dataItems: items,
}
currentAccountDataset.accounts = append(currentAccountDataset.accounts, accountData)
} else {
log.Errorf(ctx, "[iif_data_reader.read] iif line expected reading account sign, but actual is \"%s\"", items[0])
return nil, nil, errs.ErrInvalidIIFFile
}
} else if currentDatasetType == iifTransactionSampleLineSignColumnName && currentTransactionDataset != nil {
if lastLineSign == "" {
if items[0] == iifTransactionLineSignColumnName {
currentTransactionData = &iifTransactionData{
dataItems: items,
splitData: make([]*iifTransactionSplitData, 0),
}
lastLineSign = items[0]
} else {
log.Errorf(ctx, "[iif_data_reader.read] iif line expected reading transaction sign, but actual is \"%s\"", items[0])
return nil, nil, errs.ErrInvalidIIFFile
}
} else if lastLineSign == iifTransactionLineSignColumnName || lastLineSign == iifTransactionSplitLineSignColumnName {
if items[0] == iifTransactionSplitLineSignColumnName {
currentTransactionData.splitData = append(currentTransactionData.splitData, &iifTransactionSplitData{
dataItems: items,
})
lastLineSign = items[0]
} else if items[0] == iifTransactionEndLineSignColumnName {
currentTransactionDataset.transactions = append(currentTransactionDataset.transactions, currentTransactionData)
lastLineSign = ""
} else {
log.Errorf(ctx, "[iif_data_reader.read] iif line expected reading split sign or transaction end sign, but actual is \"%s\"", items[0])
return nil, nil, errs.ErrInvalidIIFFile
}
} else {
log.Errorf(ctx, "[iif_data_reader.read] iif missing transaction sample end line")
return nil, nil, errs.ErrInvalidIIFFile
}
} // not process (read data line) for other dataset type
}
if lastLineSign != "" {
log.Errorf(ctx, "[iif_data_reader.read] iif missing transaction end line")
return nil, nil, errs.ErrInvalidIIFFile
}
if currentAccountDataset != nil {
allAccountDatasets = append(allAccountDatasets, currentAccountDataset)
}
if currentTransactionDataset != nil {
allTransactionDatasets = append(allTransactionDatasets, currentTransactionDataset)
}
return allAccountDatasets, allTransactionDatasets, nil
}
func (r *iifDataReader) readAccountSampleLine(ctx core.Context, items []string) (*iifAccountDataset, error) {
accountSampleItems := items
accountDataColumnIndexes := make(map[string]int, len(accountSampleItems))
for i := 1; i < len(accountSampleItems); i++ {
columnName := accountSampleItems[i]
accountDataColumnIndexes[columnName] = i
}
return &iifAccountDataset{
accountDataColumnIndexes: accountDataColumnIndexes,
accounts: make([]*iifAccountData, 0),
}, nil
}
func (r *iifDataReader) readTransactionSampleLines(ctx core.Context, items []string) (*iifTransactionDataset, error) {
transactionSampleItems := items
transactionDataColumnIndexes := make(map[string]int, len(transactionSampleItems))
for i := 1; i < len(transactionSampleItems); i++ {
columnName := transactionSampleItems[i]
transactionDataColumnIndexes[columnName] = i
}
splitSampleItems, err := r.reader.Read()
if err == io.EOF {
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction split sample line, but read eof")
return nil, errs.ErrInvalidIIFFile
}
if len(splitSampleItems) < 1 || splitSampleItems[0] != iifTransactionSplitSampleLineSignColumnName {
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction split sample line, but read \"%s\"", strings.Join(splitSampleItems, "\t"))
return nil, errs.ErrInvalidIIFFile
}
splitDataColumnIndexes := make(map[string]int, len(splitSampleItems))
for i := 1; i < len(splitSampleItems); i++ {
columnName := splitSampleItems[i]
splitDataColumnIndexes[columnName] = i
}
transactionEndSampleItems, err := r.reader.Read()
if err == io.EOF {
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction end sample line, but read eof")
return nil, errs.ErrInvalidIIFFile
}
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"))
return nil, errs.ErrInvalidIIFFile
}
return &iifTransactionDataset{
transactionDataColumnIndexes: transactionDataColumnIndexes,
splitDataColumnIndexes: splitDataColumnIndexes,
transactions: make([]*iifTransactionData, 0),
}, nil
}
func createNewIifDataReader(data []byte) *iifDataReader {
reader := bytes.NewReader(data)
csvReader := csv.NewReader(reader)
csvReader.Comma = '\t'
csvReader.FieldsPerRecord = -1
return &iifDataReader{
reader: csvReader,
}
}
@@ -0,0 +1,43 @@
package iif
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var iifTransactionTypeNameMapping = 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)),
}
// iifTransactionDataFileImporter defines the structure of intuit interchange format (iif) for transaction data
type iifTransactionDataFileImporter struct{}
// Initialize an intuit interchange format (iif) file importer singleton instance
var (
IifTransactionDataFileImporter = &iifTransactionDataFileImporter{}
)
// ParseImportedData returns the imported data by parsing the intuit interchange format (iif) data
func (c *iifTransactionDataFileImporter) 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) {
iifDataReader := createNewIifDataReader(data)
accountDatasets, transactionDatasets, err := iifDataReader.read(ctx)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable, err := createNewIIfTransactionDataTable(ctx, accountDatasets, transactionDatasets)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := datatable.CreateNewSimpleImporter(iifTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,680 @@
package iif
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestIIFTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
"!ACCNT\tNAME\tACCNTTYPE\n"+
"ACCNT\tTest Account\tBANK\n"+
"ACCNT\tTest Account2\tBANK\n"+
"ACCNT\tTest Category\tINC\n"+
"ACCNT\tTest Category2\tEXP\n"+
"!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\txxx\t-123.45\n"+
"ENDTRNS\t\t\t\t\n"+
"TRNS\tDEPOSIT\t09/02/2024\tTest Account\t0.12\n"+
"SPL\tDEPOSIT\t09/02/2024\tTest Category\t-0.12\n"+
"ENDTRNS\t\t\t\t\n"+
"TRNS\tCREDIT CARD\t09/03/2024\tTest Account\t-1.00\n"+
"SPL\tCREDIT CARD\t09/03/2024\tTest Category2\t1.00\n"+
"ENDTRNS\t\t\t\t\n"+
"TRNS\tTRANSFER\t09/04/2024\tTest Account\t-0.05\n"+
"SPL\tTRANSFER\t09/04/2024\tTest Account2\t0.05\n"+
"ENDTRNS\t\t\t\t\n"+
"TRNS\tGENERAL JOURNAL\t09/05/2024\tTest Account\t0.06\n"+
"SPL\tGENERAL JOURNAL\t09/05/2024\tTest Account2\t-0.06\n"+
"ENDTRNS\t\t\t\t\n"+
"TRNS\tDEPOSIT\t09/06/2024\tTest Category\t-23.45\n"+
"SPL\tDEPOSIT\t09/06/2024\tTest Account2\t23.45\n"+
"ENDTRNS\t\t\t\t\n"+
"TRNS\tCREDIT CARD\t09/07/2024\tTest Category2\t34.56\n"+
"SPL\tCREDIT CARD\t09/07/2024\tTest Account2\t-34.56\n"+
"ENDTRNS\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 7, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type)
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Category", 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(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), 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(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", 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(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
assert.Equal(t, int64(6), allNewTransactions[4].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[4].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[5].Type)
assert.Equal(t, int64(1725580800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime))
assert.Equal(t, int64(2345), allNewTransactions[5].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[5].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[5].OriginalDestinationAccountName)
assert.Equal(t, "Test Category", allNewTransactions[5].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[6].Type)
assert.Equal(t, int64(1725667200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[6].TransactionTime))
assert.Equal(t, int64(3456), allNewTransactions[6].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[6].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[6].OriginalDestinationAccountName)
assert.Equal(t, "Test Category2", allNewTransactions[6].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), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
}
func TestIIFTransactionDataFileParseImportedData_MinimumValidDataWithoutAccountData(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\t09/01/2024\tTest Account\t123.45\n"+
"SPL\t09/01/2024\tTest Category\t-123.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Category", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
}
func TestIIFTransactionDataFileParseImportedData_MultipleDataset(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!ACCNT\tNAME\tACCNTTYPE\n"+
"ACCNT\tTest Account3\tBANK\n"+
"ACCNT\tTest Account4\tBANK\n"+
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t09/05/2024\tTest Account\t-0.05\n"+
"SPL\t09/05/2024\tTest Account2\t0.05\n"+
"ENDTRNS\t\t\t\n"+
"!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tNAME\tCLASS\tAMOUNT\tDOCNUM\tMEMO\tCLEAR\tTOPRINT\tADDR5\tDUEDATE\tTERMS\n"+
"!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tNAME\tCLASS\tAMOUNT\tDOCNUM\tMEMO\tCLEAR\tQNTY\tREIMBEXP\tSERVICEDATE\tOTHER2\n"+
"!ENDTRNS\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n"+
"TRNS\t\tTRANSFER\t09/04/2024\tTest Account3\t\tTest Class\t123.45\t\t\t\t\t\t\t\n"+
"SPL\t\tTRANSFER\t09/04/2024\tTest Account4\t\t\t-123.45\t\t\t\t\t\t\t\n"+
"ENDTRNS\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n"+
"!CLASS\tNAME\tHIDDEN\n"+
"CLASS\tTest Class\tN\n"+
"!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\txxx\t-123.45\n"+
"ENDTRNS\t\t\t\t\n"+
"TRNS\tDEPOSIT\t09/02/2024\tTest Account\t0.12\n"+
"SPL\tDEPOSIT\t09/02/2024\tTest Category\t-0.12\n"+
"ENDTRNS\t\t\t\t\n"+
"TRNS\tCREDIT CARD\t09/03/2024\tTest Account\t-1.00\n"+
"SPL\tCREDIT CARD\t09/03/2024\tTest Category2\t1.00\n"+
"ENDTRNS\t\t\t\t\n"+
"!ACCNT\tTEST\tNAME\tACCNTTYPE\n"+
"ACCNT\t\tTest Category\tINC\n"+
"ACCNT\t\tTest Category2\tEXP\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 5, len(allNewTransactions))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type)
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Category", 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(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), 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(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[3].Amount)
assert.Equal(t, "Test Account4", 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(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[4].Amount)
assert.Equal(t, "Test Account", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[4].OriginalCategoryName)
}
func TestIIFTransactionDataFileParseImportedData_ParseCategoryAndSubCategory(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, allNewSubExpenseCategories, allNewSubIncomeCategories, _, _, err := converter.ParseImportedData(context, user, []byte(
"!ACCNT\tNAME\tACCNTTYPE\n"+
"ACCNT\tTest Parent Category:Test Category\tINC\n"+
"ACCNT\tTest Parent Category2:Test 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 Parent Category:Test Category\t-123.45\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t09/02/2024\tTest Account2\t-123.45\n"+
"SPL\t09/02/2024\tTest Parent Category2:Test Category2\t123.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
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(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, "Test Category", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalDestinationAccountName)
assert.Equal(t, "Test Category2", allNewTransactions[1].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
}
func TestIIFTransactionDataFileParseImportedData_ParseYearMonthDayFormatTime(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\t2024/09/01\tTest Account\t123.45\n"+
"SPL\t2024/09/01\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t2024/09/2\tTest Account\t123.45\n"+
"SPL\t2024/09/2\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t2024/9/03\tTest Account\t123.45\n"+
"SPL\t2024/9/03\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t2024/9/4\tTest Account\t123.45\n"+
"SPL\t2024/9/4\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, 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))
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
}
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayFormatTime(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/2024\tTest Account\t123.45\n"+
"SPL\t9/01/2024\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t09/2/2024\tTest Account\t123.45\n"+
"SPL\t09/2/2024\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t9/3/2024\tTest Account\t123.45\n"+
"SPL\t9/3/2024\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()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!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"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t2024-09-01\tTest Account\t123.45\n"+
"SPL\t2024-09-01\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t9/24\tTest Account\t123.45\n"+
"SPL\t9/24\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestIIFTransactionDataFileParseImportedData_ParseAmountWithThousandsSeparator(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/2024\tTest Account\t123,456.78\n"+
"SPL\t9/01/2024\tTest Account2\t-123,456.78\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(12345678), allNewTransactions[0].Amount)
}
func TestIIFTransactionDataFileParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, 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-123.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, 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-123 45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestIIFTransactionDataFileParseImportedData_ParseDescription(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-123.45\t\n"+
"ENDTRNS\t\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "foo bar\t#test", allNewTransactions[0].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-123.45\t\n"+
"ENDTRNS\t\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test", allNewTransactions[0].Comment)
}
func TestIIFTransactionDataFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, 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.ErrNotSupportedSplitTransactions.Message)
}
func TestIIFTransactionDataFileParseImportedData_InvalidDataLines(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Transaction Line
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\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.ErrInvalidIIFFile.Message)
// Missing Split Line
_, _, _, _, _, _, 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"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Missing Transaction End Line
_, _, _, _, _, _, 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-123.45\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Missing Transaction End Line (following is another header)
_, _, _, _, _, _, 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-123.45\n"+
"!ACCNT\tNAME\tACCNTTYPE\n"+
"ACCNT\tTest Account\tBANK\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Invalid Line
_, _, _, _, _, _, 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-123.45\n"+
"TEST\t\t\t\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Repeat Transaction Line
_, _, _, _, _, _, 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"+
"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.ErrInvalidIIFFile.Message)
// Repeat Transaction End Line
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\t\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\t\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t09/01/2024\tTest Account\t123.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"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
}
func TestIIFTransactionDataFileParseImportedData_InvalidHeaderLines(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing All Sample Lines
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"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.ErrInvalidIIFFile.Message)
// Missing Transaction Sample Line
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Missing Split Sample Line
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Missing Transaction End Sample Line
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Missing Transaction End Sample Line (following is data line)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\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.ErrInvalidIIFFile.Message)
// Invalid Sample Line
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!TEST\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
}
func TestIIFTransactionDataFileParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Date Column
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!TRNS\tACCNT\tAMOUNT\t\n"+
"!SPL\tACCNT\tAMOUNT\t\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\tTest Account\t123.45\n"+
"SPL\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tAMOUNT\t\n"+
"!SPL\tDATE\tAMOUNT\t\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t09/01/2024\t123.45\n"+
"SPL\t09/01/2024\t-123.45\n"+
"ENDTRNS\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\t\n"+
"!SPL\tDATE\tACCNT\t\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t09/01/2024\tTest Account\n"+
"SPL\t09/01/2024\tTest Account2\n"+
"ENDTRNS\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
@@ -0,0 +1,392 @@
package iif
import (
"fmt"
"strings"
"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"
)
const iifAccountNameColumnName = "NAME"
const iifAccountTypeColumnName = "ACCNTTYPE"
const iifAccountTypeIncome = "INC"
const iifAccountTypeExpense = "EXP"
const iifTransactionTypeColumnName = "TRNSTYPE"
const iifTransactionDateColumnName = "DATE"
const iifTransactionAccountNameColumnName = "ACCNT"
const iifTransactionNameColumnName = "NAME"
const iifTransactionAmountColumnName = "AMOUNT"
const iifTransactionMemoColumnName = "MEMO"
const iifTransactionTypeBeginningBalance = "BEGINBALCHECK"
const iifTransactionCategorySeparator = ":"
var iifTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
datatable.TRANSACTION_DATA_TABLE_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
}
// iifTransactionDataTable defines the structure of intuit interchange format (iif) transaction data table
type iifTransactionDataTable struct {
incomeAccountNames map[string]bool
expenseAccountNames map[string]bool
transactionDatasets []*iifTransactionDataset
}
// iifTransactionDataRow defines the structure of intuit interchange format (iif) transaction data row
type iifTransactionDataRow struct {
dataTable *iifTransactionDataTable
finalItems map[datatable.TransactionDataTableColumn]string
}
// iifTransactionDataRowIterator defines the structure of intuit interchange format (iif) transaction data row iterator
type iifTransactionDataRowIterator struct {
dataTable *iifTransactionDataTable
currentDatasetIndex int
currentIndexInDataset int
}
// HasColumn returns whether the transaction data table has specified column
func (t *iifTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
_, exists := iifTransactionSupportedColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *iifTransactionDataTable) TransactionRowCount() int {
totalDataRowCount := 0
for i := 0; i < len(t.transactionDatasets); i++ {
transactions := t.transactionDatasets[i]
totalDataRowCount += len(transactions.transactions)
}
return totalDataRowCount
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *iifTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &iifTransactionDataRowIterator{
dataTable: t,
currentDatasetIndex: 0,
currentIndexInDataset: -1,
}
}
// IsValid returns whether this row is valid data for importing
func (r *iifTransactionDataRow) IsValid() bool {
return true
}
// GetData returns the data in the specified column type
func (r *iifTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
_, exists := iifTransactionSupportedColumns[column]
if exists {
return r.finalItems[column]
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *iifTransactionDataRowIterator) HasNext() bool {
allDatasets := t.dataTable.transactionDatasets
if t.currentDatasetIndex >= len(allDatasets) {
return false
}
currentDataset := allDatasets[t.currentDatasetIndex]
if t.currentIndexInDataset+1 < len(currentDataset.transactions) {
return true
}
for i := t.currentDatasetIndex + 1; i < len(allDatasets); i++ {
dataset := allDatasets[i]
if len(dataset.transactions) < 1 {
continue
}
return true
}
return false
}
// 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++ {
dataset := allDatasets[i]
if currentIndexInDataset+1 < len(dataset.transactions) {
t.currentIndexInDataset++
currentIndexInDataset = t.currentIndexInDataset
break
}
t.currentDatasetIndex++
t.currentIndexInDataset = -1
currentIndexInDataset = -1
}
if t.currentDatasetIndex >= len(allDatasets) {
return nil, nil
}
currentDataset := allDatasets[t.currentDatasetIndex]
if t.currentIndexInDataset >= len(currentDataset.transactions) {
return nil, nil
}
data := currentDataset.transactions[t.currentIndexInDataset]
rowItems, err := t.parseTransaction(ctx, user, currentDataset, data)
if err != nil {
return nil, err
}
return &iifTransactionDataRow{
dataTable: t.dataTable,
finalItems: rowItems,
}, 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
}
var err error
data := make(map[datatable.TransactionDataTableColumn]string, len(iifTransactionSupportedColumns))
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME], err = t.parseTransactionTime(dataset, transactionData)
if err != nil {
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, ",", ""))
if err != nil {
return nil, errs.ErrAmountInvalid
}
amountNum2, err := utils.ParseAmount(strings.ReplaceAll(amount2, ",", ""))
if err != nil {
return nil, errs.ErrAmountInvalid
}
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_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
} else {
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all income account", accountName1, accountName2)
return nil, errs.ErrInvalidIIFFile
}
categoryNames := strings.Split(categoryName, iifTransactionCategorySeparator)
if len(categoryNames) > 1 {
data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = categoryNames[0]
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categoryNames[len(categoryNames)-1]
} else {
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categoryName
}
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
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
} else {
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all expense account", accountName1, accountName2)
return nil, errs.ErrInvalidIIFFile
}
categoryNames := strings.Split(categoryName, iifTransactionCategorySeparator)
if len(categoryNames) > 1 {
data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = categoryNames[0]
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categoryNames[len(categoryNames)-1]
} else {
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categoryName
}
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName
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] = ""
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 memo, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionMemoColumnName); memo != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = memo
} else if name, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionNameColumnName); name != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = name
} else {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
}
return data, nil
}
func (t *iifTransactionDataRowIterator) parseTransactionTime(dataset *iifTransactionDataset, transactionData *iifTransactionData) (string, error) {
date, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionDateColumnName)
dateParts := strings.Split(date, "/")
if len(dateParts) != 3 {
return "", errs.ErrTransactionTimeInvalid
}
month := dateParts[0]
day := dateParts[1]
year := dateParts[2]
if utils.IsValidYearMonthDayLongOrShortDateFormat(date) {
year = dateParts[0]
month = dateParts[1]
day = dateParts[2]
}
if len(month) < 2 {
month = "0" + month
}
if len(day) < 2 {
day = "0" + day
}
return fmt.Sprintf("%s-%s-%s 00:00:00", year, month, day), nil
}
func createNewIIfTransactionDataTable(ctx core.Context, accountDatasets []*iifAccountDataset, transactionDatasets []*iifTransactionDataset) (*iifTransactionDataTable, error) {
if len(transactionDatasets) < 1 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
incomeAccountNames, expenseAccountNames := getIncomeAndExpenseAccountNameMap(accountDatasets)
for i := 0; i < len(transactionDatasets); i++ {
transactionDataset := transactionDatasets[i]
for _, requiredColumnName := range []string{
iifTransactionDateColumnName,
iifTransactionAccountNameColumnName,
iifTransactionAmountColumnName,
} {
if _, exists := transactionDataset.transactionDataColumnIndexes[requiredColumnName]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
}
}
return &iifTransactionDataTable{
incomeAccountNames: incomeAccountNames,
expenseAccountNames: expenseAccountNames,
transactionDatasets: transactionDatasets,
}, nil
}
func getIncomeAndExpenseAccountNameMap(accountDatasets []*iifAccountDataset) (incomeAccountNames map[string]bool, expenseAccountNames map[string]bool) {
incomeAccountNames = make(map[string]bool)
expenseAccountNames = make(map[string]bool)
for i := 0; i < len(accountDatasets); i++ {
accountDataset := accountDatasets[i]
accountNameColumnIndex, accountNameColumnExists := accountDataset.accountDataColumnIndexes[iifAccountNameColumnName]
accountTypeColumnIndex, accountTypeColumnExists := accountDataset.accountDataColumnIndexes[iifAccountTypeColumnName]
if !accountNameColumnExists || accountNameColumnIndex < 0 ||
!accountTypeColumnExists || accountTypeColumnIndex < 0 {
continue
}
for j := 0; j < len(accountDataset.accounts); j++ {
items := accountDataset.accounts[j].dataItems
if accountNameColumnIndex >= len(items) ||
accountTypeColumnIndex >= len(items) {
continue
}
accountName := items[accountNameColumnIndex]
accountType := items[accountTypeColumnIndex]
if accountType == iifAccountTypeIncome {
incomeAccountNames[accountName] = true
} else if accountType == iifAccountTypeExpense {
expenseAccountNames[accountName] = true
}
}
}
return incomeAccountNames, expenseAccountNames
}
+188
View File
@@ -0,0 +1,188 @@
package ofx
import (
"encoding/xml"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// oFXDeclarationVersion represents the declaration version of open financial exchange (ofx) file
type oFXDeclarationVersion string
const (
ofxVersion1 oFXDeclarationVersion = "100"
ofxVersion2 oFXDeclarationVersion = "200"
)
const ofxDefaultTimezoneOffset = "+00:00"
// ofxAccountType represents account type in open financial exchange (ofx) file
type ofxAccountType string
// OFX account types
const (
ofxCheckingAccount ofxAccountType = "CHECKING"
ofxSavingsAccount ofxAccountType = "SAVINGS"
ofxMoneyMarketAccount ofxAccountType = "MONEYMRKT"
ofxLineOfCreditAccount ofxAccountType = "CREDITLINE"
ofxCertificateOfDepositAccount ofxAccountType = "CD"
)
// ofxTransactionType represents transaction type in open financial exchange (ofx) file
type ofxTransactionType string
// OFX transaction types
const (
ofxGenericCreditTransaction ofxTransactionType = "CREDIT"
ofxGenericDebitTransaction ofxTransactionType = "DEBIT"
ofxInterestTransaction ofxTransactionType = "INT"
ofxDividendTransaction ofxTransactionType = "DIV"
ofxFIFeeTransaction ofxTransactionType = "FEE"
ofxServiceChargeTransaction ofxTransactionType = "SRVCHG"
ofxDepositTransaction ofxTransactionType = "DEP"
ofxATMTransaction ofxTransactionType = "ATM"
ofxPOSTransaction ofxTransactionType = "POS"
ofxTransferTransaction ofxTransactionType = "XFER"
ofxCheckTransaction ofxTransactionType = "CHECK"
ofxElectronicPaymentTransaction ofxTransactionType = "PAYMENT"
ofxCashWithdrawalTransaction ofxTransactionType = "CASH"
ofxDirectDepositTransaction ofxTransactionType = "DIRECTDEP"
ofxMerchantInitiatedDebitTransaction ofxTransactionType = "DIRECTDEBIT"
ofxRepeatingPaymentTransaction ofxTransactionType = "REPEATPMT"
ofxHoldTransaction ofxTransactionType = "HOLD"
ofxOtherTransaction ofxTransactionType = "OTHER"
)
var ofxTransactionTypeMapping = map[ofxTransactionType]models.TransactionType{
ofxGenericCreditTransaction: models.TRANSACTION_TYPE_EXPENSE,
ofxGenericDebitTransaction: models.TRANSACTION_TYPE_EXPENSE,
ofxDividendTransaction: models.TRANSACTION_TYPE_INCOME,
ofxFIFeeTransaction: models.TRANSACTION_TYPE_EXPENSE,
ofxServiceChargeTransaction: models.TRANSACTION_TYPE_EXPENSE,
ofxDepositTransaction: models.TRANSACTION_TYPE_INCOME,
ofxTransferTransaction: models.TRANSACTION_TYPE_TRANSFER,
ofxCheckTransaction: models.TRANSACTION_TYPE_EXPENSE,
ofxElectronicPaymentTransaction: models.TRANSACTION_TYPE_EXPENSE,
ofxCashWithdrawalTransaction: models.TRANSACTION_TYPE_EXPENSE,
ofxDirectDepositTransaction: models.TRANSACTION_TYPE_INCOME,
ofxMerchantInitiatedDebitTransaction: models.TRANSACTION_TYPE_EXPENSE,
ofxRepeatingPaymentTransaction: models.TRANSACTION_TYPE_EXPENSE,
}
// ofxFile represents the struct of open financial exchange (ofx) file
type ofxFile struct {
XMLName xml.Name `xml:"OFX"`
FileHeader *ofxFileHeader
BankMessageResponseV1 *ofxBankMessageResponseV1 `xml:"BANKMSGSRSV1"`
CreditCardMessageResponseV1 *ofxCreditCardMessageResponseV1 `xml:"CREDITCARDMSGSRSV1"`
}
// ofxFileHeader represents the struct of open financial exchange (ofx) file header
type ofxFileHeader struct {
OFXDeclarationVersion oFXDeclarationVersion
OFXDataVersion string
Security string
OldFileUid string
NewFileUid string
}
// ofxBankMessageResponseV1 represents the struct of open financial exchange (ofx) bank message response v1
type ofxBankMessageResponseV1 struct {
StatementTransactionResponse *ofxBankStatementTransactionResponse `xml:"STMTTRNRS"`
}
// ofxCreditCardMessageResponseV1 represents the struct of open financial exchange (ofx) credit card message response v1
type ofxCreditCardMessageResponseV1 struct {
StatementTransactionResponse *ofxCreditCardStatementTransactionResponse `xml:"CCSTMTTRNRS"`
}
// ofxBankStatementTransactionResponse represents the struct of open financial exchange (ofx) bank statement transaction response
type ofxBankStatementTransactionResponse struct {
StatementResponse *ofxBankStatementResponse `xml:"STMTRS"`
}
// ofxCreditCardStatementTransactionResponse represents the struct of open financial exchange (ofx) credit card statement transaction response
type ofxCreditCardStatementTransactionResponse struct {
StatementResponse *ofxCreditCardStatementResponse `xml:"CCSTMTRS"`
}
// ofxBankStatementResponse represents the struct of open financial exchange (ofx) bank statement response
type ofxBankStatementResponse struct {
DefaultCurrency string `xml:"CURDEF"`
AccountFrom *ofxBankAccount `xml:"BANKACCTFROM"`
TransactionList *ofxBankTransactionList `xml:"BANKTRANLIST"`
}
// ofxCreditCardStatementResponse represents the struct of open financial exchange (ofx) credit card statement response
type ofxCreditCardStatementResponse struct {
DefaultCurrency string `xml:"CURDEF"`
AccountFrom *ofxCreditCardAccount `xml:"CCACCTFROM"`
TransactionList *ofxCreditCardTransactionList `xml:"BANKTRANLIST"`
}
// ofxBankAccount represents the struct of open financial exchange (ofx) bank account
type ofxBankAccount struct {
BankId string `xml:"BANKID"`
BranchId string `xml:"BRANCHID"`
AccountId string `xml:"ACCTID"`
AccountType ofxAccountType `xml:"ACCTTYPE"`
AccountKey string `xml:"ACCTKEY"`
}
// ofxCreditCardAccount represents the struct of open financial exchange (ofx) credit card account
type ofxCreditCardAccount struct {
AccountId string `xml:"ACCTID"`
AccountKey string `xml:"ACCTKEY"`
}
// ofxBankTransactionList represents the struct of open financial exchange (ofx) bank transaction list
type ofxBankTransactionList struct {
StartDate string `xml:"DTSTART"`
EndDate string `xml:"DTEND"`
StatementTransactions []*ofxBankStatementTransaction `xml:"STMTTRN"`
}
// ofxCreditCardTransactionList represents the struct of open financial exchange (ofx) credit card transaction list
type ofxCreditCardTransactionList struct {
StartDate string `xml:"DTSTART"`
EndDate string `xml:"DTEND"`
StatementTransactions []*ofxCreditCardStatementTransaction `xml:"STMTTRN"`
}
// ofxBaseStatementTransaction represents the struct of open financial exchange (ofx) base statement transaction
type ofxBaseStatementTransaction struct {
TransactionId string `xml:"FITID"`
TransactionType ofxTransactionType `xml:"TRNTYPE"`
PostedDate string `xml:"DTPOSTED"`
Amount string `xml:"TRNAMT"`
Name string `xml:"NAME"`
Payee *ofxPayee `xml:"PAYEE"`
Memo string `xml:"MEMO"`
Currency string `xml:"CURRENCY"`
OriginalCurrency string `xml:"ORIGCURRENCY"`
}
// ofxBankStatementTransaction represents the struct of open financial exchange (ofx) bank statement transaction
type ofxBankStatementTransaction struct {
ofxBaseStatementTransaction
AccountTo *ofxBankAccount `xml:"BANKACCTTO"`
}
// ofxCreditCardStatementTransaction represents the struct of open financial exchange (ofx) credit card statement transaction
type ofxCreditCardStatementTransaction struct {
ofxBaseStatementTransaction
AccountTo *ofxCreditCardAccount `xml:"CCACCTTO"`
}
// ofxPayee represents the struct of open financial exchange (ofx) payee info
type ofxPayee struct {
Name string `xml:"NAME"`
Address1 string `xml:"ADDR1"`
Address2 string `xml:"ADDR2"`
Address3 string `xml:"ADDR3"`
City string `xml:"CITY"`
State string `xml:"STATE"`
PostalCode string `xml:"POSTALCODE"`
Country string `xml:"COUNTRY"`
Phone string `xml:"PHONE"`
}

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