Compare commits

...

224 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
422 changed files with 31004 additions and 5082 deletions
+4 -1
View File
@@ -8,6 +8,9 @@ module.exports = {
'plugin:vue/vue3-essential'
],
'rules': {
'vue/no-use-v-if-with-v-for': 'off'
'vue/no-use-v-if-with-v-for': 'off',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}]
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
# Build backend binary file
FROM golang:1.21.12-alpine3.20 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 --platform=$BUILDPLATFORM node:18.20.3-alpine3.20 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.20.1
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
+1 -1
View File
@@ -31,7 +31,7 @@ Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-dem
7. Multi-language support
8. Two-factor authentication
9. Application lock (PIN code / WebAuthn)
10. Data export
10. Data import & export
## Screenshots
### Desktop Version
+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
}
+26 -17
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,7 @@ 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))
@@ -122,7 +123,15 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction template table maintained successfully")
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
}
+26 -17
View File
@@ -4,8 +4,8 @@ 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"
@@ -17,7 +17,7 @@ import (
"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")
@@ -25,26 +25,26 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if configFilePath != "" {
if _, err = os.Stat(configFilePath); err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] cannot load configuration from custom config path %s, because file not exists", configFilePath)
log.BootErrorf(c, "[initializer.initializeSystem] cannot load configuration from custom config path %s, because file not exists", configFilePath)
}
return nil, err
}
if !isDisableBootLog {
log.BootInfof("[initializer.initializeSystem] will loading configuration from custom config path %s", configFilePath)
log.BootInfof(c, "[initializer.initializeSystem] will loading configuration from custom config path %s", configFilePath)
}
} else {
configFilePath, err = settings.GetDefaultConfigFilePath()
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] cannot get default configuration path, because %s", err.Error())
log.BootErrorf(c, "[initializer.initializeSystem] cannot get default configuration path, because %s", err.Error())
}
return nil, err
}
if !isDisableBootLog {
log.BootInfof("[initializer.initializeSystem] will load configuration from default config path %s", configFilePath)
log.BootInfof(c, "[initializer.initializeSystem] will load configuration from default config path %s", configFilePath)
}
}
@@ -52,13 +52,13 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] cannot load configuration, because %s", err.Error())
log.BootErrorf(c, "[initializer.initializeSystem] cannot load configuration, because %s", err.Error())
}
return nil, err
}
if config.SecretKeyNoSet {
log.BootWarnf("[initializer.initializeSystem] \"secret_key\" in config file is not set, please change it to keep your user data safe")
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)
@@ -67,7 +67,7 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] initializes data store failed, because %s", err.Error())
log.BootErrorf(c, "[initializer.initializeSystem] initializes data store failed, because %s", err.Error())
}
return nil, err
}
@@ -76,7 +76,7 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] sets logger configuration failed, because %s", err.Error())
log.BootErrorf(c, "[initializer.initializeSystem] sets logger configuration failed, because %s", err.Error())
}
return nil, err
}
@@ -85,7 +85,7 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] initializes object storage failed, because %s", err.Error())
log.BootErrorf(c, "[initializer.initializeSystem] initializes object storage failed, because %s", err.Error())
}
return nil, err
}
@@ -94,7 +94,7 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] initializes uuid generator failed, because %s", err.Error())
log.BootErrorf(c, "[initializer.initializeSystem] initializes uuid generator failed, because %s", err.Error())
}
return nil, err
}
@@ -103,7 +103,16 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] initializes duplicate checker failed, because %s", err.Error())
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
}
@@ -112,7 +121,7 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] initializes mailer failed, because %s", err.Error())
log.BootErrorf(c, "[initializer.initializeSystem] initializes mailer failed, because %s", err.Error())
}
return nil, err
}
@@ -121,7 +130,7 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] initializes exchange rates data source failed, because %s", err.Error())
log.BootErrorf(c, "[initializer.initializeSystem] initializes exchange rates data source failed, because %s", err.Error())
}
return nil, err
}
@@ -129,7 +138,7 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
cfgJson, _ := json.Marshal(getConfigWithoutSensitiveData(config))
if !isDisableBootLog {
log.BootInfof("[initializer.initializeSystem] has loaded configuration %s", cfgJson)
log.BootInfof(c, "[initializer.initializeSystem] has loaded configuration %s", cfgJson)
}
return config, nil
+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 {
+143 -68
View File
@@ -7,6 +7,7 @@ import (
"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"
@@ -21,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",
@@ -58,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",
@@ -71,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",
@@ -90,7 +91,7 @@ var UserData = &cli.Command{
{
Name: "user-enable",
Usage: "Enable specified user",
Action: enableUser,
Action: bindAction(enableUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -103,7 +104,7 @@ var UserData = &cli.Command{
{
Name: "user-disable",
Usage: "Disable specified user",
Action: disableUser,
Action: bindAction(disableUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -116,7 +117,7 @@ var UserData = &cli.Command{
{
Name: "user-resend-verify-email",
Usage: "Resend user verify email",
Action: resendUserVerifyEmail,
Action: bindAction(resendUserVerifyEmail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -129,7 +130,7 @@ var UserData = &cli.Command{
{
Name: "user-set-email-verified",
Usage: "Set user email address verified",
Action: setUserEmailVerified,
Action: bindAction(setUserEmailVerified),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -142,7 +143,7 @@ var UserData = &cli.Command{
{
Name: "user-set-email-unverified",
Usage: "Set user email address unverified",
Action: setUserEmailUnverified,
Action: bindAction(setUserEmailUnverified),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -155,7 +156,7 @@ var UserData = &cli.Command{
{
Name: "user-delete",
Usage: "Delete specified user",
Action: deleteUser,
Action: bindAction(deleteUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -168,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",
@@ -181,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",
@@ -194,7 +195,7 @@ 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",
@@ -207,7 +208,7 @@ var UserData = &cli.Command{
{
Name: "send-password-reset-mail",
Usage: "Send password reset mail",
Action: sendPasswordResetMail,
Action: bindAction(sendPasswordResetMail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -220,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",
@@ -233,7 +234,7 @@ 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: fixTransactionTagIndexNotHaveTransactionTime,
Action: bindAction(fixTransactionTagIndexNotHaveTransactionTime),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -243,10 +244,35 @@ var UserData = &cli.Command{
},
},
},
{
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 file",
Action: exportUserTransaction,
Action: bindAction(exportUserTransaction),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -271,7 +297,7 @@ var UserData = &cli.Command{
},
}
func addNewUser(c *cli.Context) error {
func addNewUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -287,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
}
@@ -296,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 {
@@ -307,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
}
@@ -316,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 {
@@ -328,16 +354,16 @@ 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 sendPasswordResetMail(c *cli.Context) error {
func sendPasswordResetMail(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -348,16 +374,16 @@ func sendPasswordResetMail(c *cli.Context) error {
err = clis.UserData.SendPasswordResetMail(c, username)
if err != nil {
log.BootErrorf("[user_data.sendPasswordResetMail] error occurs when sending password reset email")
log.CliErrorf(c, "[user_data.sendPasswordResetMail] error occurs when sending password reset email")
return err
}
log.BootInfof("[user_data.sendPasswordResetMail] a password reset email for user \"%s\" has been sent", username)
log.CliInfof(c, "[user_data.sendPasswordResetMail] a password reset email for user \"%s\" has been sent", username)
return nil
}
func enableUser(c *cli.Context) error {
func enableUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -368,16 +394,16 @@ func enableUser(c *cli.Context) error {
err = clis.UserData.EnableUser(c, username)
if err != nil {
log.BootErrorf("[user_data.enableUser] error occurs when setting user enabled")
log.CliErrorf(c, "[user_data.enableUser] error occurs when setting user enabled")
return err
}
log.BootInfof("[user_data.enableUser] user \"%s\" has been set enabled", username)
log.CliInfof(c, "[user_data.enableUser] user \"%s\" has been set enabled", username)
return nil
}
func disableUser(c *cli.Context) error {
func disableUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -388,16 +414,16 @@ func disableUser(c *cli.Context) error {
err = clis.UserData.DisableUser(c, username)
if err != nil {
log.BootErrorf("[user_data.disableUser] error occurs when setting user disabled")
log.CliErrorf(c, "[user_data.disableUser] error occurs when setting user disabled")
return err
}
log.BootInfof("[user_data.disableUser] user \"%s\" has been set disabled", username)
log.CliInfof(c, "[user_data.disableUser] user \"%s\" has been set disabled", username)
return nil
}
func resendUserVerifyEmail(c *cli.Context) error {
func resendUserVerifyEmail(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -408,16 +434,16 @@ func resendUserVerifyEmail(c *cli.Context) error {
err = clis.UserData.ResendVerifyEmail(c, username)
if err != nil {
log.BootErrorf("[user_data.resendUserVerifyEmail] error occurs when resending user verify email")
log.CliErrorf(c, "[user_data.resendUserVerifyEmail] error occurs when resending user verify email")
return err
}
log.BootInfof("[user_data.resendUserVerifyEmail] verify email for user \"%s\" has been resent", username)
log.CliInfof(c, "[user_data.resendUserVerifyEmail] verify email for user \"%s\" has been resent", username)
return nil
}
func setUserEmailVerified(c *cli.Context) error {
func setUserEmailVerified(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -428,16 +454,16 @@ func setUserEmailVerified(c *cli.Context) error {
err = clis.UserData.SetUserEmailVerified(c, username)
if err != nil {
log.BootErrorf("[user_data.setUserEmailVerified] error occurs when setting user email address verified")
log.CliErrorf(c, "[user_data.setUserEmailVerified] error occurs when setting user email address verified")
return err
}
log.BootInfof("[user_data.setUserEmailVerified] user \"%s\" email address has been set verified", username)
log.CliInfof(c, "[user_data.setUserEmailVerified] user \"%s\" email address has been set verified", username)
return nil
}
func setUserEmailUnverified(c *cli.Context) error {
func setUserEmailUnverified(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -448,16 +474,16 @@ func setUserEmailUnverified(c *cli.Context) error {
err = clis.UserData.SetUserEmailUnverified(c, username)
if err != nil {
log.BootErrorf("[user_data.setUserEmailUnverified] error occurs when setting user email address unverified")
log.CliErrorf(c, "[user_data.setUserEmailUnverified] error occurs when setting user email address unverified")
return err
}
log.BootInfof("[user_data.setUserEmailUnverified] user \"%s\" email address has been set unverified", username)
log.CliInfof(c, "[user_data.setUserEmailUnverified] user \"%s\" email address has been set unverified", username)
return nil
}
func deleteUser(c *cli.Context) error {
func deleteUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -468,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 {
@@ -488,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 {
@@ -508,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
}
@@ -523,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 {
@@ -534,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 {
@@ -552,21 +578,21 @@ 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 fixTransactionTagIndexNotHaveTransactionTime(c *cli.Context) error {
func fixTransactionTagIndexNotHaveTransactionTime(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -575,21 +601,21 @@ func fixTransactionTagIndexNotHaveTransactionTime(c *cli.Context) error {
username := c.String("username")
log.BootInfof("[user_data.fixTransactionTagIndexNotHaveTransactionTime] starting fixing user \"%s\" transaction tag index data", 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.BootErrorf("[user_data.fixTransactionTagIndexNotHaveTransactionTime] error occurs when fixing user data")
log.CliErrorf(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] error occurs when fixing user data")
return err
}
log.BootInfof("[user_data.fixTransactionTagIndexNotHaveTransactionTime] user transaction tag index data has been fixed successfully")
log.CliInfof(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] user transaction tag index data has been fixed successfully")
return nil
}
func exportUserTransaction(c *cli.Context) error {
func exportUserTransaction(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -601,39 +627,88 @@ func exportUserTransaction(c *cli.Context) error {
fileType := c.String("type")
if fileType != "" && fileType != "csv" && fileType != "tsv" {
log.BootErrorf("[user_data.exportUserTransaction] export file type is not supported")
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 unspecified")
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, 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
}
+7 -6
View File
@@ -7,6 +7,7 @@ import (
"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"
@@ -21,7 +22,7 @@ var Utilities = &cli.Command{
{
Name: "parse-default-request-id",
Usage: "Parse a request id which is generated by default request generator and show the details",
Action: parseRequestId,
Action: bindAction(parseRequestId),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
@@ -33,7 +34,7 @@ var Utilities = &cli.Command{
{
Name: "send-test-mail",
Usage: "Send an email to specified e-mail address",
Action: sendTestMail,
Action: bindAction(sendTestMail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "to",
@@ -45,15 +46,15 @@ var Utilities = &cli.Command{
},
}
func parseRequestId(c *cli.Context) error {
func parseRequestId(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
err = requestid.InitializeRequestIdGenerator(config)
defaultGenerator, err := requestid.NewDefaultRequestIdGenerator(config)
err = requestid.InitializeRequestIdGenerator(c, config)
defaultGenerator, err := requestid.NewDefaultRequestIdGenerator(c, config)
if err != nil {
return err
@@ -73,7 +74,7 @@ func parseRequestId(c *cli.Context) error {
return nil
}
func sendTestMail(c *cli.Context) error {
func sendTestMail(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
+49 -22
View File
@@ -15,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"
@@ -32,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("[webserver.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("[webserver.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("[webserver.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
}
@@ -68,7 +76,7 @@ func startWebServer(c *cli.Context) error {
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
}
log.BootInfof("[webserver.startWebServer] %s%s", serverInfo, uuidServerInfo)
log.BootInfof(c, "[webserver.startWebServer] %s%s", serverInfo, uuidServerInfo)
if config.Mode == settings.MODE_PRODUCTION {
gin.SetMode(gin.ReleaseMode)
@@ -145,7 +153,7 @@ func startWebServer(c *cli.Context) error {
router.StaticFile("/desktop/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
}
if config.AvatarProvider == settings.InternalAvatarProvider {
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
avatarRoute := router.Group("/avatar")
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
{
@@ -153,6 +161,14 @@ func startWebServer(c *cli.Context) error {
}
}
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 {
@@ -253,7 +269,7 @@ func startWebServer(c *cli.Context) error {
apiV1Route.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler))
apiV1Route.POST("/users/profile/update.json", bindApiWithTokenUpdate(api.Users.UserUpdateProfileHandler, config))
if config.AvatarProvider == settings.InternalAvatarProvider {
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))
}
@@ -301,6 +317,17 @@ func startWebServer(c *cli.Context) error {
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))
@@ -337,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("[webserver.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("[webserver.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("[webserver.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("[webserver.startWebServer] cannot start, because %s", err)
log.BootErrorf(c, "[webserver.startWebServer] cannot start, because %s", err)
return err
}
@@ -359,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 {
@@ -378,7 +405,7 @@ func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
result, err := fn(c)
if err == nil && config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
@@ -395,7 +422,7 @@ func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin
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 {
@@ -408,7 +435,7 @@ func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
func bindTsv(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 {
@@ -421,7 +448,7 @@ func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
func bindImage(fn core.ImageHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
result, contentType, err := fn(c)
if err != nil {
@@ -434,7 +461,7 @@ func bindImage(fn core.ImageHandlerFunc) gin.HandlerFunc {
func bindCachedImage(fn core.ImageHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
result, contentType, err := fn(c)
if err != nil {
@@ -447,7 +474,7 @@ func bindCachedImage(fn core.ImageHandlerFunc, store persistence.CacheStore) gin
func bindProxy(fn core.ProxyHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
proxy, err := fn(c)
if err != nil {
+32 -7
View File
@@ -146,10 +146,17 @@ 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 page is considered as a new session)
# 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 =
@@ -192,16 +199,34 @@ 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
@@ -291,12 +316,12 @@ custom_map_tile_server_default_zoom_level = 14
[exchange_rates]
# Exchange rates data source, supports the following types:
# "euro_central_bank"
# "bank_of_canada"
# "reserve_bank_of_australia",
# "czech_national_bank"
# "national_bank_of_poland"
# "monetary_authority_of_singapore"
# "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)
+1
View File
@@ -36,6 +36,7 @@ func main() {
cmd.WebServer,
cmd.Database,
cmd.UserData,
cmd.CronJobs,
cmd.SecurityUtils,
cmd.Utilities,
},
+15 -7
View File
@@ -1,12 +1,14 @@
module github.com/mayswind/ezbookkeeping
go 1.21
go 1.22
require (
github.com/boombuler/barcode v1.0.2
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
github.com/gin-contrib/cache v1.3.0
github.com/gin-contrib/gzip v1.0.1
github.com/gin-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
@@ -17,9 +19,10 @@ require (
github.com/pquerna/otp v1.4.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.1
github.com/urfave/cli/v2 v2.27.3
github.com/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.25.0
golang.org/x/crypto v0.26.0
golang.org/x/text v0.17.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/mail.v2 v2.3.1
xorm.io/builder v0.3.13
@@ -37,9 +40,11 @@ require (
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.2 // 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
@@ -49,6 +54,7 @@ require (
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/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
@@ -61,17 +67,19 @@ require (
github.com/modern-go/reflect2 v1.0.2 // 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.12 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // 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.22.0 // indirect
golang.org/x/text v0.16.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
+32 -12
View File
@@ -25,13 +25,19 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
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.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
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/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/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=
@@ -43,6 +49,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-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=
@@ -67,6 +75,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
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/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=
@@ -77,6 +87,9 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -109,6 +122,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
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=
@@ -131,21 +146,25 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
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/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/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.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
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-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
@@ -154,16 +173,17 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+1331 -689
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "ezbookkeeping",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"repository": {
"type": "git",
@@ -19,8 +19,8 @@
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@vuepic/vue-datepicker": "^8.8.1",
"axios": "^1.7.2",
"@vuepic/vue-datepicker": "^9.0.1",
"axios": "^1.7.3",
"cbor-js": "^0.1.0",
"clipboard": "^2.0.11",
"crypto-js": "^4.2.0",
@@ -34,18 +34,18 @@
"line-awesome": "^1.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"pinia": "^2.1.7",
"pinia": "^2.2.1",
"register-service-worker": "^1.7.2",
"skeleton-elements": "^4.0.1",
"swiper": "^10.2.0",
"ua-parser-js": "^1.0.38",
"vue": "^3.4.31",
"vue": "^3.4.37",
"vue-echarts": "^6.7.3",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.0",
"vue-router": "^4.4.3",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.6.11"
"vuetify": "^3.6.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
+55 -47
View File
@@ -16,23 +16,31 @@ import (
// 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) (any, *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)
}
@@ -40,7 +48,7 @@ func (a *AccountsApi) AccountListHandler(c *core.Context) (any, *errs.Error) {
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)
}
@@ -87,12 +95,12 @@ func (a *AccountsApi) AccountListHandler(c *core.Context) (any, *errs.Error) {
}
// AccountGetHandler returns one specific account of current user
func (a *AccountsApi) AccountGetHandler(c *core.Context) (any, *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)
}
@@ -100,7 +108,7 @@ func (a *AccountsApi) AccountGetHandler(c *core.Context) (any, *errs.Error) {
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)
}
@@ -130,50 +138,50 @@ func (a *AccountsApi) AccountGetHandler(c *core.Context) (any, *errs.Error) {
}
// AccountCreateHandler saves a new account by request parameters for current user
func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *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.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] cannot get client timezone offset, because %s", err.Error())
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_INVESTMENT {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account category invalid, category is %d", accountCreateReq.Category)
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
}
@@ -181,22 +189,22 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *errs.Error) {
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
}
@@ -204,25 +212,25 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *errs.Error) {
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)
if settings.Container.Current.EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" {
found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId)
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId)
if found {
log.InfofWithRequestId(c, "[accounts.AccountCreateHandler] another account \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
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.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
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)
}
@@ -250,13 +258,13 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *errs.Error) {
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)
duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
accountInfoResp := mainAccount.ToAccountInfoResponse()
if len(childrenAccounts) > 0 {
@@ -271,17 +279,17 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *errs.Error) {
}
// AccountModifyHandler saves an existed account by request parameters for current user
func (a *AccountsApi) AccountModifyHandler(c *core.Context) (any, *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_INVESTMENT {
log.WarnfWithRequestId(c, "[accounts.AccountModifyHandler] account category invalid, category is %d", accountModifyReq.Category)
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
}
@@ -289,7 +297,7 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (any, *errs.Error) {
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)
}
@@ -335,11 +343,11 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (any, *errs.Error) {
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)
@@ -382,12 +390,12 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (any, *errs.Error) {
}
// AccountHideHandler hides an existed account by request parameters for current user
func (a *AccountsApi) AccountHideHandler(c *core.Context) (any, *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)
}
@@ -395,21 +403,21 @@ func (a *AccountsApi) AccountHideHandler(c *core.Context) (any, *errs.Error) {
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) (any, *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)
}
@@ -430,21 +438,21 @@ func (a *AccountsApi) AccountMoveHandler(c *core.Context) (any, *errs.Error) {
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) (any, *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)
}
@@ -452,11 +460,11 @@ func (a *AccountsApi) AccountDeleteHandler(c *core.Context) (any, *errs.Error) {
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
}
+8 -3
View File
@@ -18,15 +18,20 @@ 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{}
AmapApis = &AmapApiProxy{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// AmapApiProxyHandler returns amap api response
func (p *AmapApiProxy) AmapApiProxyHandler(c *core.Context) (*httputil.ReverseProxy, *errs.Error) {
func (p *AmapApiProxy) AmapApiProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
var targetUrl string
if strings.HasPrefix(c.Request.RequestURI, "/_AMapService/v4/map/styles") {
@@ -38,7 +43,7 @@ func (p *AmapApiProxy) AmapApiProxyHandler(c *core.Context) (*httputil.ReversePr
}
director := func(req *http.Request) {
targetRawUrl := fmt.Sprintf("%s?%s&jscode=%s", targetUrl, req.URL.RawQuery, settings.Container.Current.AmapApplicationSecret)
targetRawUrl := fmt.Sprintf("%s?%s&jscode=%s", targetUrl, req.URL.RawQuery, p.CurrentConfig().AmapApplicationSecret)
targetUrl, _ := url.Parse(targetRawUrl)
oldCookies := req.Cookies()
+50 -36
View File
@@ -3,6 +3,7 @@ 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"
@@ -13,6 +14,8 @@ import (
// AuthorizationsApi represents authorization api
type AuthorizationsApi struct {
ApiUsingConfig
ApiWithUserInfo
users *services.UserService
tokens *services.TokenService
twoFactorAuthorizations *services.TwoFactorAuthorizationService
@@ -21,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,
@@ -28,36 +42,36 @@ var (
)
// AuthorizeHandler verifies and authorizes current login request
func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (any, *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(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
}
if user.Disabled {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user is disabled", credential.LoginName)
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user is disabled", credential.LoginName)
return nil, errs.ErrUserIsDisabled
}
if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified {
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
hasValidEmailVerifyToken, err := a.tokens.ExistsValidTokenByType(c, user.Uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] failed check whether user \"uid:%d\" has valid verify email token, because %s", user.Uid, err.Error())
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.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user has not verified email", credential.LoginName)
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,
@@ -68,7 +82,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (any, *errs.Error)
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
@@ -77,7 +91,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (any, *errs.Error)
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)
}
}
@@ -92,7 +106,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (any, *errs.Error)
}
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
}
@@ -102,19 +116,19 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (any, *errs.Error)
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(c, token, twoFactorEnable, user)
return authResp, nil
}
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (any, *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
}
@@ -122,29 +136,29 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (any, *er
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(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
}
if user.Disabled {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
@@ -152,32 +166,32 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (any, *er
err = a.tokens.DeleteTokenByClaims(c, 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())
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.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
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(c, token, false, user)
return authResp, nil
}
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Context) (any, *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
}
@@ -185,7 +199,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont
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)
}
@@ -196,24 +210,24 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont
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
}
if user.Disabled {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" is disabled", user.Uid)
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has not verified email", user.Uid)
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)
}
@@ -221,30 +235,30 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont
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(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(c, token, false, user)
return authResp, nil
}
func (a *AuthorizationsApi) getAuthResponse(c *core.Context, 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(),
NotificationContent: settings.Container.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()),
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))
}
+77 -61
View File
@@ -19,98 +19,116 @@ const pageCountForDataExport = 1000
// DataManagementsApi represents data management api
type DataManagementsApi struct {
ezBookKeepingCsvExporter *converters.EzBookKeepingCSVFileExporter
ezBookKeepingTsvExporter *converters.EzBookKeepingTSVFileExporter
tokens *services.TokenService
users *services.UserService
accounts *services.AccountService
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
templates *services.TransactionTemplateService
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{
ezBookKeepingCsvExporter: &converters.EzBookKeepingCSVFileExporter{},
ezBookKeepingTsvExporter: &converters.EzBookKeepingTSVFileExporter{},
tokens: services.Tokens,
users: services.Users,
accounts: services.Accounts,
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
templates: services.TransactionTemplates,
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,
}
)
// ExportDataToEzbookkeepingCSVHandler returns exported data in csv format
func (a *DataManagementsApi) ExportDataToEzbookkeepingCSVHandler(c *core.Context) ([]byte, string, *errs.Error) {
func (a *DataManagementsApi) ExportDataToEzbookkeepingCSVHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
return a.getExportedFileContent(c, "csv")
}
// ExportDataToEzbookkeepingTSVHandler returns exported data in csv format
func (a *DataManagementsApi) ExportDataToEzbookkeepingTSVHandler(c *core.Context) ([]byte, string, *errs.Error) {
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) (any, *errs.Error) {
func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
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(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(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(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.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total transaction template count for user \"uid:%d\", because %s", uid, err.Error())
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,
TotalTransactionTemplateCount: totalTransactionTemplateCount,
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) (any, *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)
}
@@ -119,7 +137,7 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.Context) (any, *errs.Error
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
@@ -129,40 +147,40 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.Context) (any, *errs.Error
return nil, errs.ErrUserPasswordWrong
}
err = a.templates.DeleteAllTemplates(c, uid)
if err != nil {
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.transactions.DeleteAllTransactions(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 transactions, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.categories.DeleteAllCategories(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 transaction categories, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.tags.DeleteAllTags(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 tags, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.templates.DeleteAllTemplates(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transaction templates, 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)
log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
return true, nil
}
func (a *DataManagementsApi) getExportedFileContent(c *core.Context, fileType string) ([]byte, string, *errs.Error) {
if !settings.Container.Current.EnableDataExport {
func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType string) ([]byte, string, *errs.Error) {
if !a.CurrentConfig().EnableDataExport {
return nil, "", errs.ErrDataExportNotAllowed
}
@@ -170,7 +188,7 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.Context, fileType st
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.WarnfWithRequestId(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
} else {
timezone = time.FixedZone("Client Timezone", int(utcOffset)*60)
}
@@ -180,7 +198,7 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.Context, fileType st
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())
log.Warnf(c, "[data_managements.ExportDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, "", errs.ErrUserNotFound
@@ -189,28 +207,28 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.Context, fileType st
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
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.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
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.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
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.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
@@ -221,22 +239,20 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.Context, fileType st
allTransactions, err := a.transactions.GetAllTransactions(c, uid, pageCountForDataExport, true)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
var dataExporter converters.DataConverter
dataExporter := converters.GetTransactionDataExporter(fileType)
if fileType == "tsv" {
dataExporter = a.ezBookKeepingTsvExporter
} else {
dataExporter = a.ezBookKeepingCsvExporter
if dataExporter == nil {
return nil, "", errs.ErrNotImplemented
}
result, err := dataExporter.ToExportedContent(uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes)
result, err := dataExporter.ToExportedContent(c, uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error())
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)
}
+2 -2
View File
@@ -14,11 +14,11 @@ var (
)
// ApiNotFound returns api not found error
func (a *DefaultApi) ApiNotFound(c *core.Context) (any, *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) (any, *errs.Error) {
func (a *DefaultApi) MethodNotAllowed(c *core.WebContext) (any, *errs.Error) {
return nil, errs.ErrMethodNotAllowed
}
+15 -9
View File
@@ -18,15 +18,21 @@ import (
)
// 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) (any, *errs.Error) {
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
dataSource := exchangerates.Container.Current
if dataSource == nil {
@@ -36,9 +42,9 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (any, *err
uid := c.GetCurrentUid()
transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, settings.Container.Current.ExchangeRatesProxy)
utils.SetProxyUrl(transport, a.CurrentConfig().ExchangeRatesProxy)
if settings.Container.Current.ExchangeRatesSkipTLSVerify {
if a.CurrentConfig().ExchangeRatesSkipTLSVerify {
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
@@ -46,7 +52,7 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (any, *err
client := &http.Client{
Transport: transport,
Timeout: time.Duration(settings.Container.Current.ExchangeRatesRequestTimeout) * time.Millisecond,
Timeout: time.Duration(a.CurrentConfig().ExchangeRatesRequestTimeout) * time.Millisecond,
}
urls := dataSource.GetRequestUrls()
@@ -59,12 +65,12 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (any, *err
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
}
@@ -73,7 +79,7 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (any, *err
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)
}
+24 -20
View File
@@ -13,6 +13,7 @@ import (
// ForgetPasswordsApi represents user forget password api
type ForgetPasswordsApi struct {
ApiUsingConfig
users *services.UserService
tokens *services.TokenService
forgetPasswords *services.ForgetPasswordService
@@ -21,6 +22,9 @@ type ForgetPasswordsApi struct {
// Initialize a user api singleton instance
var (
ForgetPasswords = &ForgetPasswordsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
users: services.Users,
tokens: services.Tokens,
forgetPasswords: services.ForgetPasswords,
@@ -28,12 +32,12 @@ var (
)
// UserForgetPasswordRequestHandler generates password reset link and send user an email with this link
func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.Context) (any, *errs.Error) {
func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext) (any, *errs.Error) {
var request models.ForgetPasswordRequest
err := c.ShouldBindJSON(&request)
if err != nil {
log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrEmailIsEmptyOrInvalid
}
@@ -41,30 +45,30 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.Context) (
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" is disabled", user.Uid)
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" has not verified email", user.Uid)
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 !settings.Container.Current.EnableSMTP {
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreatePasswordResetToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
@@ -72,7 +76,7 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.Context) (
err = a.forgetPasswords.SendPasswordResetEmail(c, user, token, c.GetClientLocale())
if err != nil {
log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
@@ -80,12 +84,12 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.Context) (
}
// UserResetPasswordHandler resets user password by request parameters
func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (any, *errs.Error) {
func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any, *errs.Error) {
var request models.PasswordResetRequest
err := c.ShouldBindJSON(&request)
if err != nil {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -94,24 +98,24 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (any, *er
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" is disabled", user.Uid)
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" has not verified email", user.Uid)
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.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
return nil, errs.ErrEmptyIsInvalid
}
@@ -120,7 +124,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (any, *er
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
if err != nil {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke password reset token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
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
@@ -135,7 +139,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (any, *er
_, _, err = a.users.UpdateUser(c, userNew, false)
if err != nil {
log.ErrorfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
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)
}
@@ -143,9 +147,9 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (any, *er
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
if err == nil {
log.InfofWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
log.Infof(c, "[forget_passwords.UserResetPasswordHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
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) (any, *errs.Error) {
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
result := make(map[string]string)
result["version"] = settings.Version
+18 -13
View File
@@ -24,16 +24,21 @@ const tianDiTuMapAnnotationUrlFormat = "https://t0.tianditu.gov.cn/cva_w/wmts?SE
// MapImageProxy represents map image proxy
type MapImageProxy struct {
ApiUsingConfig
}
// Initialize a map image proxy singleton instance
var (
MapImages = &MapImageProxy{}
MapImages = &MapImageProxy{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// MapTileImageProxyHandler returns map tile image
func (p *MapImageProxy) MapTileImageProxyHandler(c *core.Context) (*httputil.ReverseProxy, *errs.Error) {
return p.mapImageProxyHandler(c, func(c *core.Context, mapProvider string) (string, *errs.Error) {
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 {
@@ -47,7 +52,7 @@ func (p *MapImageProxy) MapTileImageProxyHandler(c *core.Context) (*httputil.Rev
} else if mapProvider == settings.CartoDBMapProvider {
return cartoDBMapTileImageUrlFormat, nil
} else if mapProvider == settings.TomTomMapProvider {
targetUrl := tomtomMapTileImageUrlFormat + "?key=" + settings.Container.Current.TomTomMapAPIKey
targetUrl := tomtomMapTileImageUrlFormat + "?key=" + p.CurrentConfig().TomTomMapAPIKey
language := c.Query("language")
if language != "" {
@@ -56,9 +61,9 @@ func (p *MapImageProxy) MapTileImageProxyHandler(c *core.Context) (*httputil.Rev
return targetUrl, nil
} else if mapProvider == settings.TianDiTuProvider {
return tianDiTuMapTileImageUrlFormat + "&tk=" + settings.Container.Current.TianDiTuAPIKey, nil
return tianDiTuMapTileImageUrlFormat + "&tk=" + p.CurrentConfig().TianDiTuAPIKey, nil
} else if mapProvider == settings.CustomProvider {
return settings.Container.Current.CustomMapTileServerTileLayerUrl, nil
return p.CurrentConfig().CustomMapTileServerTileLayerUrl, nil
}
return "", errs.ErrParameterInvalid
@@ -66,23 +71,23 @@ func (p *MapImageProxy) MapTileImageProxyHandler(c *core.Context) (*httputil.Rev
}
// MapAnnotationImageProxyHandler returns map annotation image
func (p *MapImageProxy) MapAnnotationImageProxyHandler(c *core.Context) (*httputil.ReverseProxy, *errs.Error) {
return p.mapImageProxyHandler(c, func(c *core.Context, mapProvider string) (string, *errs.Error) {
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=" + settings.Container.Current.TianDiTuAPIKey, nil
return tianDiTuMapAnnotationUrlFormat + "&tk=" + p.CurrentConfig().TianDiTuAPIKey, nil
} else if mapProvider == settings.CustomProvider {
return settings.Container.Current.CustomMapTileServerAnnotationLayerUrl, nil
return p.CurrentConfig().CustomMapTileServerAnnotationLayerUrl, nil
}
return "", errs.ErrParameterInvalid
})
}
func (p *MapImageProxy) mapImageProxyHandler(c *core.Context, fn func(c *core.Context, mapProvider string) (string, *errs.Error)) (*httputil.ReverseProxy, *errs.Error) {
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 != settings.Container.Current.MapProvider {
if mapProvider != p.CurrentConfig().MapProvider {
return nil, errs.ErrMapProviderNotCurrent
}
@@ -105,7 +110,7 @@ func (p *MapImageProxy) mapImageProxyHandler(c *core.Context, fn func(c *core.Co
}
transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, settings.Container.Current.MapProxy)
utils.SetProxyUrl(transport, p.CurrentConfig().MapProxy)
director := func(req *http.Request) {
imageRawUrl := targetUrl
+9 -4
View File
@@ -19,16 +19,21 @@ const (
// QrCodesApi represents qrcode generator api
type QrCodesApi struct {
ApiUsingConfig
}
// Initialize a qrcode generator api singleton instance
var (
QrCodes = &QrCodesApi{}
QrCodes = &QrCodesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// MobileUrlQrCodeHandler returns a mobile url qr code image
func (a *QrCodesApi) MobileUrlQrCodeHandler(c *core.Context) ([]byte, string, *errs.Error) {
fullUrl := settings.Container.Current.RootUrl + "mobile"
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 {
@@ -38,7 +43,7 @@ func (a *QrCodesApi) MobileUrlQrCodeHandler(c *core.Context) ([]byte, string, *e
return data, "image/png", nil
}
func (a *QrCodesApi) generateUrlQrCode(c *core.Context, url string) ([]byte, *errs.Error) {
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{}
+42 -28
View File
@@ -4,6 +4,7 @@ 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"
@@ -15,6 +16,8 @@ import (
// TokensApi represents token api
type TokensApi struct {
ApiUsingConfig
ApiWithUserInfo
tokens *services.TokenService
users *services.UserService
}
@@ -22,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) (any, *errs.Error) {
func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
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)
}
@@ -62,7 +76,7 @@ func (a *TokensApi) TokenListHandler(c *core.Context) (any, *errs.Error) {
}
// TokenRevokeCurrentHandler revokes current token of current user
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.Context) (any, *errs.Error) {
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
_, claims, err := a.tokens.ParseTokenByHeader(c)
if err != nil {
@@ -72,7 +86,7 @@ func (a *TokensApi) TokenRevokeCurrentHandler(c *core.Context) (any, *errs.Error
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)
}
@@ -86,21 +100,21 @@ func (a *TokensApi) TokenRevokeCurrentHandler(c *core.Context) (any, *errs.Error
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, claims.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\"", claims.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) (any, *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)
}
@@ -108,7 +122,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.Context) (any, *errs.Error) {
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)
@@ -117,28 +131,28 @@ func (a *TokensApi) TokenRevokeHandler(c *core.Context) (any, *errs.Error) {
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(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) (any, *errs.Error) {
func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
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)
}
@@ -159,34 +173,34 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (any, *errs.Error) {
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) (any, *errs.Error) {
func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
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
}
now := time.Now().Unix()
oldTokenClaims := c.GetTokenClaims()
if now-oldTokenClaims.IssuedAt < int64(settings.Container.Current.TokenMinRefreshInterval) {
log.InfofWithRequestId(c, "[token.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid)
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.WarnfWithRequestId(c, "[tokens.TokenRefreshHandler] parse user token id failed, because %s", err.Error())
log.Warnf(c, "[tokens.TokenRefreshHandler] parse user token id failed, because %s", err.Error())
} else {
tokenRecord := &models.TokenRecord{
Uid: oldTokenClaims.Uid,
@@ -199,13 +213,13 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (any, *errs.Error) {
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
if err != nil {
log.WarnfWithRequestId(c, "[token.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
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: user.ToUserBasicInfo(),
NotificationContent: settings.Container.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
}
return refreshResp, nil
@@ -214,7 +228,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (any, *errs.Error) {
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)
}
@@ -228,13 +242,13 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (any, *errs.Error) {
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(),
NotificationContent: settings.Container.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
}
return refreshResp, nil
+53 -45
View File
@@ -17,23 +17,31 @@ import (
// 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) (any, *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)
}
@@ -41,7 +49,7 @@ func (a *TransactionCategoriesApi) CategoryListHandler(c *core.Context) (any, *e
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)
}
@@ -49,12 +57,12 @@ func (a *TransactionCategoriesApi) CategoryListHandler(c *core.Context) (any, *e
}
// CategoryGetHandler returns one specific transaction category of current user
func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (any, *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)
}
@@ -62,7 +70,7 @@ func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (any, *er
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)
}
@@ -72,17 +80,17 @@ func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (any, *er
}
// CategoryCreateHandler saves a new transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (any, *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
}
@@ -92,17 +100,17 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (any,
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
}
}
@@ -116,24 +124,24 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (any,
}
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)
if settings.Container.Current.EnableDuplicateSubmissionsCheck && categoryCreateReq.ClientSessionId != "" {
found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId)
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && categoryCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId)
if found {
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateHandler] another category \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
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.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get existed category \"id:%d\" for user \"uid:%d\", because %s", categoryId, uid, err.Error())
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)
}
@@ -147,25 +155,25 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (any,
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)
duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(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) (any, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.WebContext) (any, *errs.Error) {
var categoryCreateBatchReq models.TransactionCategoryCreateBatchRequest
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)
}
@@ -181,12 +189,12 @@ func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.Context) (
}
// CategoryModifyHandler saves an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (any, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (any, *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())
log.Warnf(c, "[transaction_categories.CategoryModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -194,7 +202,7 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (any,
category, err := a.categories.GetCategoryByCategoryId(c, 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())
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)
}
@@ -230,14 +238,14 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (any,
fromPrimaryCategory, err := a.categories.GetCategoryByCategoryId(c, uid, category.ParentCategoryId)
if err != nil {
log.ErrorfWithRequestId(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())
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.ErrorfWithRequestId(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())
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)
}
@@ -253,11 +261,11 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (any,
err = a.categories.ModifyCategory(c, 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())
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.InfofWithRequestId(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
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
@@ -267,12 +275,12 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (any,
}
// CategoryHideHandler hides an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.Context) (any, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.WebContext) (any, *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())
log.Warnf(c, "[transaction_categories.CategoryHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -280,21 +288,21 @@ func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.Context) (any, *e
err = a.categories.HideCategory(c, 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())
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.InfofWithRequestId(c, "[transaction_categories.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, categoryHideReq.Id)
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.Context) (any, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.WebContext) (any, *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())
log.Warnf(c, "[transaction_categories.CategoryMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -315,21 +323,21 @@ func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.Context) (any, *e
err = a.categories.ModifyCategoryDisplayOrders(c, uid, categories)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryMoveHandler] failed to move categories for user \"uid:%d\", because %s", uid, err.Error())
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.InfofWithRequestId(c, "[transaction_categories.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
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.Context) (any, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryDeleteHandler(c *core.WebContext) (any, *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())
log.Warnf(c, "[transaction_categories.CategoryDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -337,15 +345,15 @@ func (a *TransactionCategoriesApi) CategoryDeleteHandler(c *core.Context) (any,
err = a.categories.DeleteCategory(c, 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())
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.InfofWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] user \"uid:%d\" has deleted category \"id:%d\"", uid, categoryDeleteReq.Id)
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.Context, uid int64, categoryCreateBatchReq *models.TransactionCategoryCreateBatchRequest) ([]*models.TransactionCategory, error) {
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)
@@ -360,7 +368,7 @@ func (a *TransactionCategoriesApi) createBatchCategories(c *core.Context, uid in
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)
}
}
@@ -388,11 +396,11 @@ func (a *TransactionCategoriesApi) createBatchCategories(c *core.Context, uid in
categories, err := a.categories.CreateCategories(c, uid, categoriesMap)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.createBatchCategories] 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.createBatchCategories] user \"uid:%d\" has created categories successfully", uid)
log.Infof(c, "[transaction_categories.createBatchCategories] user \"uid:%d\" has created categories successfully", uid)
return categories, nil
}
+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,
}
}
+28 -28
View File
@@ -23,12 +23,12 @@ var (
)
// TagListHandler returns transaction tag list of current user
func (a *TransactionTagsApi) TagListHandler(c *core.Context) (any, *errs.Error) {
func (a *TransactionTagsApi) TagListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
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,12 +44,12 @@ func (a *TransactionTagsApi) TagListHandler(c *core.Context) (any, *errs.Error)
}
// TagGetHandler returns one specific transaction tag of current user
func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (any, *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)
}
@@ -57,7 +57,7 @@ func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (any, *errs.Error) {
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,12 +67,12 @@ func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (any, *errs.Error) {
}
// TagCreateHandler saves a new transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagCreateHandler(c *core.Context) (any, *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)
}
@@ -81,7 +81,7 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.Context) (any, *errs.Error
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)
}
@@ -90,11 +90,11 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.Context) (any, *errs.Error
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,12 +102,12 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.Context) (any, *errs.Error
}
// TagModifyHandler saves an existed transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (any, *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)
}
@@ -115,7 +115,7 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (any, *errs.Error
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)
}
@@ -132,11 +132,11 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (any, *errs.Error
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,13 +144,13 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (any, *errs.Error
return tagResp, nil
}
// TagHideHandler hides an transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagHideHandler(c *core.Context) (any, *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.TagHideHandler] 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)
}
@@ -158,21 +158,21 @@ func (a *TransactionTagsApi) TagHideHandler(c *core.Context) (any, *errs.Error)
err = a.tags.HideTag(c, uid, []int64{tagHideReq.Id}, tagHideReq.Hidden)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagHideHandler] 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.TagHideHandler] user \"uid:%d\" has hidden tag \"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) (any, *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.TagMoveHandler] 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)
}
@@ -193,21 +193,21 @@ func (a *TransactionTagsApi) TagMoveHandler(c *core.Context) (any, *errs.Error)
err = a.tags.ModifyTagDisplayOrders(c, uid, tags)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagMoveHandler] 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.TagMoveHandler] user \"uid:%d\" has moved tags", 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) (any, *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)
}
@@ -215,11 +215,11 @@ func (a *TransactionTagsApi) TagDeleteHandler(c *core.Context) (any, *errs.Error
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
}
+217 -44
View File
@@ -3,6 +3,7 @@ package api
import (
"sort"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
@@ -14,38 +15,52 @@ import (
"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.Context) (any, *errs.Error) {
func (a *TransactionTemplatesApi) TemplateListHandler(c *core.WebContext) (any, *errs.Error) {
var templateListReq models.TransactionTemplateListRequest
err := c.ShouldBindQuery(&templateListReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_templates.TemplateListHandler] parse request failed, because %s", err.Error())
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_NORMAL {
log.WarnfWithRequestId(c, "[transaction_templates.TemplateListHandler] template type invalid, type is %d", templateListReq.TemplateType)
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.ErrorfWithRequestId(c, "[transaction_templates.TemplateListHandler] failed to get templates for user \"uid:%d\", because %s", uid, err.Error())
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)
}
@@ -62,12 +77,12 @@ func (a *TransactionTemplatesApi) TemplateListHandler(c *core.Context) (any, *er
}
// TemplateGetHandler returns one specific transaction template of current user
func (a *TransactionTemplatesApi) TemplateGetHandler(c *core.Context) (any, *errs.Error) {
func (a *TransactionTemplatesApi) TemplateGetHandler(c *core.WebContext) (any, *errs.Error) {
var templateGetReq models.TransactionTemplateGetRequest
err := c.ShouldBindQuery(&templateGetReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_templates.TemplateGetHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_templates.TemplateGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -75,10 +90,14 @@ func (a *TransactionTemplatesApi) TemplateGetHandler(c *core.Context) (any, *err
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateGetReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_templates.TemplateGetHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateGetReq.Id, uid, err.Error())
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)
@@ -86,49 +105,71 @@ func (a *TransactionTemplatesApi) TemplateGetHandler(c *core.Context) (any, *err
}
// TemplateCreateHandler saves a new transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.Context) (any, *errs.Error) {
func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any, *errs.Error) {
var templateCreateReq models.TransactionTemplateCreateRequest
err := c.ShouldBindJSON(&templateCreateReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_templates.TemplateCreateHandler] parse request failed, because %s", err.Error())
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_NORMAL {
log.WarnfWithRequestId(c, "[transaction_templates.TemplateCreateHandler] template type invalid, type is %d", templateCreateReq.TemplateType)
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.WarnfWithRequestId(c, "[transaction_templates.TemplateCreateHandler] transaction type invalid, type is %d", templateCreateReq.Type)
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.ErrorfWithRequestId(c, "[transaction_templates.TemplateCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
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 settings.Container.Current.EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
if found {
log.InfofWithRequestId(c, "[transaction_templates.TemplateCreateHandler] another template \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
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.ErrorfWithRequestId(c, "[transaction_templates.TemplateCreateHandler] failed to get existed template \"id:%d\" for user \"uid:%d\", because %s", templateId, uid, err.Error())
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)
}
@@ -142,30 +183,30 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.Context) (any, *
err = a.templates.CreateTemplate(c, template)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_templates.TemplateCreateHandler] failed to create template \"id:%d\" for user \"uid:%d\", because %s", template.TemplateId, uid, err.Error())
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.InfofWithRequestId(c, "[transaction_templates.TemplateCreateHandler] user \"uid:%d\" has created a new template \"id:%d\" successfully", uid, template.TemplateId)
log.Infof(c, "[transaction_templates.TemplateCreateHandler] user \"uid:%d\" has created a new template \"id:%d\" successfully", uid, template.TemplateId)
duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId, utils.Int64ToString(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.Context) (any, *errs.Error) {
func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any, *errs.Error) {
var templateModifyReq models.TransactionTemplateModifyRequest
err := c.ShouldBindJSON(&templateModifyReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_templates.TemplateModifyHandler] parse request failed, because %s", err.Error())
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.WarnfWithRequestId(c, "[transaction_templates.TemplateModifyHandler] transaction type invalid, type is %d", templateModifyReq.Type)
log.Warnf(c, "[transaction_templates.TemplateModifyHandler] transaction type invalid, type is %d", templateModifyReq.Type)
return nil, errs.ErrTransactionTypeInvalid
}
@@ -173,10 +214,32 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.Context) (any, *
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateModifyReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_templates.TemplateModifyHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateModifyReq.Id, uid, err.Error())
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,
@@ -192,6 +255,13 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.Context) (any, *
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 &&
@@ -202,17 +272,26 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.Context) (any, *
newTemplate.RelatedAccountAmount == template.RelatedAccountAmount &&
newTemplate.HideAmount == template.HideAmount &&
newTemplate.Comment == template.Comment {
return nil, errs.ErrNothingWillBeUpdated
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.ErrorfWithRequestId(c, "[transaction_templates.TemplateModifyHandler] failed to update template \"id:%d\" for user \"uid:%d\", because %s", templateModifyReq.Id, uid, err.Error())
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.InfofWithRequestId(c, "[transaction_templates.TemplateModifyHandler] user \"uid:%d\" has updated template \"id:%d\" successfully", uid, templateModifyReq.Id)
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
@@ -223,39 +302,65 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.Context) (any, *
return templateResp, nil
}
// TemplateHideHandler hides an transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateHideHandler(c *core.Context) (any, *errs.Error) {
// 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.WarnfWithRequestId(c, "[transaction_templates.TemplateHideHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_templates.TemplateHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.templates.HideTemplate(c, uid, []int64{templateHideReq.Id}, templateHideReq.Hidden)
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateHideReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_templates.TemplateHideHandler] failed to hide template \"id:%d\" for user \"uid:%d\", because %s", templateHideReq.Id, uid, err.Error())
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)
}
log.InfofWithRequestId(c, "[transaction_templates.TemplateHideHandler] user \"uid:%d\" has hidden template \"id:%d\"", uid, templateHideReq.Id)
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.Context) (any, *errs.Error) {
func (a *TransactionTemplatesApi) TemplateMoveHandler(c *core.WebContext) (any, *errs.Error) {
var templateMoveReq models.TransactionTemplateMoveRequest
err := c.ShouldBindJSON(&templateMoveReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_templates.CategoryMoveHandler] parse request failed, because %s", err.Error())
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++ {
@@ -272,38 +377,50 @@ func (a *TransactionTemplatesApi) TemplateMoveHandler(c *core.Context) (any, *er
err = a.templates.ModifyTemplateDisplayOrders(c, uid, templates)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_templates.TemplateMoveHandler] failed to move templates for user \"uid:%d\", because %s", uid, err.Error())
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.InfofWithRequestId(c, "[transaction_templates.TemplateMoveHandler] user \"uid:%d\" has moved templates", uid)
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.Context) (any, *errs.Error) {
func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var templateDeleteReq models.TransactionTemplateDeleteRequest
err := c.ShouldBindJSON(&templateDeleteReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_templates.TemplateDeleteHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_templates.TemplateDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.templates.DeleteTemplate(c, uid, templateDeleteReq.Id)
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateDeleteReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_templates.TemplateDeleteHandler] failed to delete template \"id:%d\" for user \"uid:%d\", because %s", templateDeleteReq.Id, uid, err.Error())
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)
}
log.InfofWithRequestId(c, "[transaction_templates.TemplateDeleteHandler] user \"uid:%d\" has deleted template \"id:%d\"", uid, templateDeleteReq.Id)
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 {
return &models.TransactionTemplate{
template := &models.TransactionTemplate{
Uid: uid,
TemplateType: templateCreateReq.TemplateType,
Name: templateCreateReq.Name,
@@ -318,4 +435,60 @@ func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCrea
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()
}
+477 -106
View File
File diff suppressed because it is too large Load Diff
+34 -34
View File
@@ -32,7 +32,7 @@ var (
)
// TwoFactorStatusHandler returns 2fa status of current user
func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (any, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
@@ -45,7 +45,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (an
}
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) (an
}
// 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) (any, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
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)
}
@@ -75,7 +75,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
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
@@ -84,14 +84,14 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
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,12 +110,12 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
}
// TwoFactorEnableConfirmHandler enables 2fa for current user
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Context) (any, *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)
}
@@ -123,7 +123,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
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)
}
@@ -135,7 +135,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
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(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(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(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(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,
@@ -198,7 +198,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
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,
@@ -209,12 +209,12 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
}
// TwoFactorDisableHandler disables 2fa for current user
func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (any, *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)
}
@@ -223,7 +223,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (a
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
@@ -236,7 +236,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (a
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)
}
@@ -247,29 +247,29 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (a
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(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) (any, *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)
}
@@ -278,7 +278,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
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
@@ -291,7 +291,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
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)
}
@@ -302,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(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)
}
@@ -317,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
}
+128 -169
View File
@@ -1,13 +1,12 @@
package api
import (
"io"
"os"
"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"
@@ -15,13 +14,14 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/storage"
"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
@@ -30,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,
@@ -37,8 +48,8 @@ var (
)
// UserRegisterHandler saves a new user by request parameters
func (a *UsersApi) UserRegisterHandler(c *core.Context) (any, *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
}
@@ -46,12 +57,12 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (any, *errs.Error) {
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
}
@@ -73,11 +84,11 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (any, *errs.Error) {
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)
presetCategoriesSaved := false
@@ -92,37 +103,37 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (any, *errs.Error) {
authResp := &models.RegisterResponse{
AuthResponse: models.AuthResponse{
Need2FA: false,
User: user.ToUserBasicInfo(),
NotificationContent: settings.Container.GetAfterRegisterNotificationContent(user.Language, c.GetClientLocale()),
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterRegisterNotificationContent(user.Language, c.GetClientLocale()),
},
NeedVerifyEmail: settings.Container.Current.EnableUserVerifyEmail && settings.Container.Current.EnableUserForceVerifyEmail,
NeedVerifyEmail: a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableUserForceVerifyEmail,
PresetCategoriesSaved: presetCategoriesSaved,
}
if settings.Container.Current.EnableUserVerifyEmail && settings.Container.Current.EnableSMTP {
if a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableSMTP {
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to create email verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
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.WarnfWithRequestId(c, "[users.UserRegisterHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
log.Warnf(c, "[users.UserRegisterHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
}
}()
}
}
if settings.Container.Current.EnableUserForceVerifyEmail {
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
}
@@ -130,13 +141,13 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (any, *errs.Error) {
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
}
// UserEmailVerifyHandler sets user email address verified
func (a *UsersApi) UserEmailVerifyHandler(c *core.Context) (any, *errs.Error) {
func (a *UsersApi) UserEmailVerifyHandler(c *core.WebContext) (any, *errs.Error) {
var userVerifyEmailReq models.UserVerifyEmailRequest
err := c.ShouldBindJSON(&userVerifyEmailReq)
@@ -145,35 +156,35 @@ func (a *UsersApi) UserEmailVerifyHandler(c *core.Context) (any, *errs.Error) {
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserEmailVerifyHandler] 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
}
if user.Disabled {
log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" is disabled", user.Uid)
log.Warnf(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.EmailVerified {
log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" email has been verified", user.Uid)
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.ErrorfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to update user \"uid:%d\" email address verified, because %s", user.Uid, err.Error())
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.InfofWithRequestId(c, "[users.UserEmailVerifyHandler] revoke old email verify tokens for user \"uid:%d\"", user.Uid)
log.Infof(c, "[users.UserEmailVerifyHandler] revoke old email verify tokens for user \"uid:%d\"", user.Uid)
} else {
log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
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{}
@@ -182,47 +193,47 @@ func (a *UsersApi) UserEmailVerifyHandler(c *core.Context) (any, *errs.Error) {
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
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 = user.ToUserBasicInfo()
resp.NotificationContent = settings.Container.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale())
resp.User = a.GetUserBasicInfo(user)
resp.NotificationContent = a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale())
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt)
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.Context) (any, *errs.Error) {
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.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
userResp := user.ToUserProfileResponse()
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserUpdateProfileHandler saves user profile by request parameters for current user
func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *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)
}
@@ -231,7 +242,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
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
@@ -277,12 +288,12 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
}
if _, exists := accountMap[userUpdateReq.DefaultAccountId]; !exists {
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] account \"id:%d\" does not exist for user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
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.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] account \"id:%d\" is hidden of user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
log.Warnf(c, "[users.UserUpdateProfileHandler] account \"id:%d\" is hidden of user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
return nil, errs.ErrUserDefaultAccountIsHidden
}
@@ -319,7 +330,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
userNew.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
anythingUpdate = true
} else {
userNew.FirstDayOfWeek = models.WEEKDAY_INVALID
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
}
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
@@ -327,7 +338,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
anythingUpdate = true
} else {
userNew.LongDateFormat = models.LONG_DATE_FORMAT_INVALID
userNew.LongDateFormat = core.LONG_DATE_FORMAT_INVALID
}
if userUpdateReq.ShortDateFormat != nil && *userUpdateReq.ShortDateFormat != user.ShortDateFormat {
@@ -335,7 +346,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
userNew.ShortDateFormat = *userUpdateReq.ShortDateFormat
anythingUpdate = true
} else {
userNew.ShortDateFormat = models.SHORT_DATE_FORMAT_INVALID
userNew.ShortDateFormat = core.SHORT_DATE_FORMAT_INVALID
}
if userUpdateReq.LongTimeFormat != nil && *userUpdateReq.LongTimeFormat != user.LongTimeFormat {
@@ -343,7 +354,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
userNew.LongTimeFormat = *userUpdateReq.LongTimeFormat
anythingUpdate = true
} else {
userNew.LongTimeFormat = models.LONG_TIME_FORMAT_INVALID
userNew.LongTimeFormat = core.LONG_TIME_FORMAT_INVALID
}
if userUpdateReq.ShortTimeFormat != nil && *userUpdateReq.ShortTimeFormat != user.ShortTimeFormat {
@@ -351,7 +362,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
userNew.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
anythingUpdate = true
} else {
userNew.ShortTimeFormat = models.SHORT_TIME_FORMAT_INVALID
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
}
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
@@ -383,7 +394,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
anythingUpdate = true
} else {
userNew.CurrencyDisplayType = models.CURRENCY_DISPLAY_TYPE_INVALID
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
}
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
@@ -436,7 +447,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
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)
}
@@ -444,28 +455,28 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
user.EmailVerified = false
}
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" has updated successfully", user.Uid)
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 && settings.Container.Current.EnableUserVerifyEmail && settings.Container.Current.EnableSMTP {
if emailSetToUnverified && a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableSMTP {
err = a.tokens.DeleteTokensByType(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
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.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to create email verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
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.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
log.Warnf(c, "[users.UserUpdateProfileHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
}
}()
}
@@ -477,15 +488,15 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
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(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
}
@@ -493,7 +504,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
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
}
@@ -502,13 +513,13 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
}
// UserUpdateAvatarHandler saves user avatar by request parameters for current user
func (a *UsersApi) UserUpdateAvatarHandler(c *core.Context) (any, *errs.Error) {
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.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
@@ -517,73 +528,61 @@ func (a *UsersApi) UserUpdateAvatarHandler(c *core.Context) (any, *errs.Error) {
form, err := c.MultipartForm()
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to get multi-part form data for user \"uid:%d\", because %s", user.Uid, err.Error())
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
}
avatars := form.File["avatar"]
avatarFiles := form.File["avatar"]
if len(avatars) < 1 {
log.WarnfWithRequestId(c, "[users.UserUpdateAvatarHandler] there is no user avatar in request for user \"uid:%d\"", user.Uid)
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 avatars[0].Size < 1 {
log.WarnfWithRequestId(c, "[users.UserUpdateAvatarHandler] the size of user avatar in request is zero for user \"uid:%d\"", user.Uid)
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
}
fileExtension := utils.GetFileNameExtension(avatars[0].Filename)
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.WarnfWithRequestId(c, "[users.UserUpdateAvatarHandler] the file extension \"%s\" of user avatar in request is not supported for user \"uid:%d\"", fileExtension, user.Uid)
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 := avatars[0].Open()
avatarFile, err := avatarFiles[0].Open()
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to get avatar file from request for user \"uid:%d\", because %s", user.Uid, err.Error())
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
}
defer avatarFile.Close()
err = storage.Container.SaveAvatar(user.Uid, avatarFile, fileExtension)
err = a.users.UpdateUserAvatar(c, user.Uid, avatarFile, fileExtension, user.CustomAvatarType)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to save avatar file for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrOperationFailed
}
err = a.users.UpdateUserAvatar(c, user.Uid, fileExtension)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
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)
}
if fileExtension != user.CustomAvatarType {
err = storage.Container.DeleteAvatar(user.Uid, user.CustomAvatarType)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to delete old avatar with extension \"%s\" for user \"uid:%d\", because %s", user.CustomAvatarType, user.Uid, err.Error())
}
}
user.CustomAvatarType = fileExtension
userResp := user.ToUserProfileResponse()
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserRemoveAvatarHandler removes user avatar by request parameters for current user
func (a *UsersApi) UserRemoveAvatarHandler(c *core.Context) (any, *errs.Error) {
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.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[users.UserRemoveAvatarHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
@@ -593,39 +592,21 @@ func (a *UsersApi) UserRemoveAvatarHandler(c *core.Context) (any, *errs.Error) {
return nil, errs.ErrNothingWillBeUpdated
}
err = storage.Container.DeleteAvatar(user.Uid, user.CustomAvatarType)
err = a.users.RemoveUserAvatar(c, user.Uid, user.CustomAvatarType)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to delete avatar file for user \"uid:%d\", because %s", user.Uid, err.Error())
exists, err := storage.Container.ExistsAvatar(user.Uid, user.CustomAvatarType)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to check whether avatar file exist for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrOperationFailed
}
if exists {
log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to delete whether avatar file exist for user \"uid:%d\", the avatar file still exist", user.Uid)
return nil, errs.ErrOperationFailed
}
}
err = a.users.UpdateUserAvatar(c, user.Uid, "")
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
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 := user.ToUserProfileResponse()
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserSendVerifyEmailByUnloginUserHandler sends unlogin user verify email
func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (any, *errs.Error) {
if !settings.Container.Current.EnableUserVerifyEmail {
func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableUserVerifyEmail {
return nil, errs.ErrEmailValidationNotAllowed
}
@@ -636,35 +617,35 @@ func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (any
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to get user, because %s", err.Error())
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.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] request password not equals to the user password")
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] request password not equals to the user password")
return nil, errs.ErrUserPasswordWrong
}
if user.Disabled {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" is disabled", user.Uid)
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.EmailVerified {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" email has been verified", user.Uid)
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
if !settings.Container.Current.EnableSMTP {
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
@@ -672,7 +653,7 @@ func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (any
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
@@ -680,8 +661,8 @@ func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (any
}
// UserSendVerifyEmailByLoginedUserHandler sends logined user verify email
func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.Context) (any, *errs.Error) {
if !settings.Container.Current.EnableUserVerifyEmail {
func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableUserVerifyEmail {
return nil, errs.ErrEmailValidationNotAllowed
}
@@ -690,25 +671,25 @@ func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.Context) (any
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.EmailVerified {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] user \"uid:%d\" email has been verified", user.Uid)
log.Warnf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
if !settings.Container.Current.EnableSMTP {
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
@@ -716,7 +697,7 @@ func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.Context) (any
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
log.Warnf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
@@ -724,58 +705,36 @@ func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.Context) (any
}
// UserGetAvatarHandler returns user avatar data for current user
func (a *UsersApi) UserGetAvatarHandler(c *core.Context) ([]byte, string, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserGetAvatarHandler] failed to get user, because %s", err.Error())
}
return nil, "", errs.ErrUserNotFound
}
if user.CustomAvatarType == "" {
log.WarnfWithRequestId(c, "[users.UserGetAvatarHandler] user does not have avatar for user \"uid:%d\"", user.Uid)
return nil, "", errs.ErrUserAvatarNoExists
}
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(user.Uid) != fileBaseName {
log.WarnfWithRequestId(c, "[users.UserGetAvatarHandler] cannot get other user avatar \"uid:%s\" for user \"uid:%d\"", fileBaseName, user.Uid)
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
}
fileExtension := utils.GetFileNameExtension(fileName)
if user.CustomAvatarType != fileExtension {
log.WarnfWithRequestId(c, "[users.UserGetAvatarHandler] user avatar extension is invalid \"%s\" for user \"uid:%d\"", fileExtension, user.Uid)
return nil, "", errs.ErrUserAvatarNoExists
}
avatarFile, err := storage.Container.ReadAvatar(user.Uid, fileExtension)
if os.IsNotExist(err) {
log.WarnfWithRequestId(c, "[users.UserGetAvatarHandler] user avatar file not exist for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, "", errs.ErrUserAvatarNoExists
}
avatarData, err := a.users.GetUserAvatar(c, uid, fileExtension)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserGetAvatarHandler] failed to get user avatar object for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, "", errs.ErrOperationFailed
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)
}
defer avatarFile.Close()
avatarData, err := io.ReadAll(avatarFile)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserGetAvatarHandler] failed to read user avatar object data for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
return avatarData, utils.GetImageContentType(fileExtension), nil
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
}
+292 -171
View File
@@ -1,16 +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"
)
@@ -19,63 +20,63 @@ const pageCountForDataExport = 1000
// UserDataCli represents user data cli
type UserDataCli struct {
ezBookKeepingCsvExporter *converters.EzBookKeepingCSVFileExporter
ezBookKeepingTsvExporter *converters.EzBookKeepingTSVFileExporter
accounts *services.AccountService
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
users *services.UserService
twoFactorAuthorizations *services.TwoFactorAuthorizationService
tokens *services.TokenService
forgetPasswords *services.ForgetPasswordService
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{},
ezBookKeepingTsvExporter: &converters.EzBookKeepingTSVFileExporter{},
accounts: services.Accounts,
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
users: services.Users,
twoFactorAuthorizations: services.TwoFactorAuthorizations,
tokens: services.Tokens,
forgetPasswords: services.ForgetPasswords,
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
}
@@ -85,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(nil, 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(nil, 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
}
@@ -119,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(nil, 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
}
@@ -147,55 +148,55 @@ func (l *UserDataCli) ModifyUserPassword(c *cli.Context, username string, passwo
Password: password,
}
_, _, err = l.users.UpdateUser(nil, userNew, false)
_, _, 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(nil, 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 *cli.Context, username string) error {
func (l *UserDataCli) SendPasswordResetMail(c *core.CliContext, username string) error {
if username == "" {
log.BootErrorf("[user_data.SendPasswordResetMail] user name is empty")
log.CliErrorf(c, "[user_data.SendPasswordResetMail] user name is empty")
return errs.ErrUsernameIsEmpty
}
user, err := l.users.GetUserByUsername(nil, username)
user, err := l.users.GetUserByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.SendPasswordResetMail] failed to get user by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.SendPasswordResetMail] failed to get user by user name \"%s\", because %s", username, err.Error())
return err
}
if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.BootWarnf("[user_data.SendPasswordResetMail] user \"uid:%d\" has not verified email", user.Uid)
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.CreatePasswordResetToken(nil, user)
token, _, err := l.tokens.CreatePasswordResetTokenWithoutUserAgent(c, user)
if err != nil {
log.BootErrorf("[user_data.SendPasswordResetMail] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
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(nil, user, token, "")
err = l.forgetPasswords.SendPasswordResetEmail(c, user, token, "")
if err != nil {
log.BootWarnf("[user_data.SendPasswordResetMail] cannot send email to \"%s\", because %s", user.Email, err.Error())
log.CliWarnf(c, "[user_data.SendPasswordResetMail] cannot send email to \"%s\", because %s", user.Email, err.Error())
return err
}
@@ -203,16 +204,16 @@ func (l *UserDataCli) SendPasswordResetMail(c *cli.Context, username string) err
}
// EnableUser sets user enabled according to the specified user name
func (l *UserDataCli) EnableUser(c *cli.Context, username string) error {
func (l *UserDataCli) EnableUser(c *core.CliContext, username string) error {
if username == "" {
log.BootErrorf("[user_data.EnableUser] user name is empty")
log.CliErrorf(c, "[user_data.EnableUser] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.EnableUser(nil, username)
err := l.users.EnableUser(c, username)
if err != nil {
log.BootErrorf("[user_data.EnableUser] failed to set user enabled by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.EnableUser] failed to set user enabled by user name \"%s\", because %s", username, err.Error())
return err
}
@@ -220,16 +221,16 @@ func (l *UserDataCli) EnableUser(c *cli.Context, username string) error {
}
// DisableUser sets user disabled according to the specified user name
func (l *UserDataCli) DisableUser(c *cli.Context, username string) error {
func (l *UserDataCli) DisableUser(c *core.CliContext, username string) error {
if username == "" {
log.BootErrorf("[user_data.DisableUser] user name is empty")
log.CliErrorf(c, "[user_data.DisableUser] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.DisableUser(nil, username)
err := l.users.DisableUser(c, username)
if err != nil {
log.BootErrorf("[user_data.DisableUser] failed to set user disabled by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.DisableUser] failed to set user disabled by user name \"%s\", because %s", username, err.Error())
return err
}
@@ -237,39 +238,39 @@ func (l *UserDataCli) DisableUser(c *cli.Context, username string) error {
}
// ResendVerifyEmail resends an email with account activation link
func (l *UserDataCli) ResendVerifyEmail(c *cli.Context, username string) error {
if !settings.Container.Current.EnableUserVerifyEmail {
func (l *UserDataCli) ResendVerifyEmail(c *core.CliContext, username string) error {
if !l.CurrentConfig().EnableUserVerifyEmail {
return errs.ErrEmailValidationNotAllowed
}
if username == "" {
log.BootErrorf("[user_data.ResendVerifyEmail] user name is empty")
log.CliErrorf(c, "[user_data.ResendVerifyEmail] user name is empty")
return errs.ErrUsernameIsEmpty
}
user, err := l.users.GetUserByUsername(nil, username)
user, err := l.users.GetUserByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.ResendVerifyEmail] failed to get user by user name \"%s\", because %s", username, err.Error())
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.BootWarnf("[user_data.ResendVerifyEmail] user \"uid:%d\" email has been verified", user.Uid)
log.CliWarnf(c, "[user_data.ResendVerifyEmail] user \"uid:%d\" email has been verified", user.Uid)
return errs.ErrEmailIsVerified
}
token, _, err := l.tokens.CreateEmailVerifyToken(nil, user)
token, _, err := l.tokens.CreateEmailVerifyTokenWithoutUserAgent(c, user)
if err != nil {
log.BootErrorf("[user_data.ResendVerifyEmail] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
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.BootErrorf("[user_data.ResendVerifyEmail] cannot send email to \"%s\", because %s", user.Email, err.Error())
log.CliErrorf(c, "[user_data.ResendVerifyEmail] cannot send email to \"%s\", because %s", user.Email, err.Error())
return err
}
@@ -277,16 +278,16 @@ func (l *UserDataCli) ResendVerifyEmail(c *cli.Context, username string) error {
}
// SetUserEmailVerified sets user email address verified
func (l *UserDataCli) SetUserEmailVerified(c *cli.Context, username string) error {
func (l *UserDataCli) SetUserEmailVerified(c *core.CliContext, username string) error {
if username == "" {
log.BootErrorf("[user_data.SetUserEmailVerified] user name is empty")
log.CliErrorf(c, "[user_data.SetUserEmailVerified] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.SetUserEmailVerified(nil, username)
err := l.users.SetUserEmailVerified(c, username)
if err != nil {
log.BootErrorf("[user_data.SetUserEmailVerified] failed to set user email address verified by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.SetUserEmailVerified] failed to set user email address verified by user name \"%s\", because %s", username, err.Error())
return err
}
@@ -294,16 +295,16 @@ func (l *UserDataCli) SetUserEmailVerified(c *cli.Context, username string) erro
}
// SetUserEmailUnverified sets user email address unverified
func (l *UserDataCli) SetUserEmailUnverified(c *cli.Context, username string) error {
func (l *UserDataCli) SetUserEmailUnverified(c *core.CliContext, username string) error {
if username == "" {
log.BootErrorf("[user_data.SetUserEmailUnverified] user name is empty")
log.CliErrorf(c, "[user_data.SetUserEmailUnverified] user name is empty")
return errs.ErrUsernameIsEmpty
}
err := l.users.SetUserEmailUnverified(nil, username)
err := l.users.SetUserEmailUnverified(c, username)
if err != nil {
log.BootErrorf("[user_data.SetUserEmailUnverified] failed to set user email address unverified by user name \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.SetUserEmailUnverified] failed to set user email address unverified by user name \"%s\", because %s", username, err.Error())
return err
}
@@ -311,16 +312,16 @@ func (l *UserDataCli) SetUserEmailUnverified(c *cli.Context, username string) er
}
// 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(nil, 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
}
@@ -328,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(nil, 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
}
@@ -352,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(nil, 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
}
@@ -377,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(nil, 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
}
@@ -401,17 +402,17 @@ func (l *UserDataCli) DisableUserTwoFactorAuthorization(c *cli.Context, username
return errs.ErrTwoFactorIsNotEnabled
}
err = l.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(nil, 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(nil, 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
}
@@ -419,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, tagIndexes, tagIndexesMap, 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
}
@@ -447,10 +448,10 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
}
}
allTransactions, err := l.transactions.GetAllTransactions(nil, 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
}
@@ -501,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.CheckTransactionAndAccount] 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
}
@@ -516,12 +517,12 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
}
if !exists && account.Balance != 0 {
log.BootErrorf("[user_data.CheckTransactionAndAccount] 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.CheckTransactionAndAccount] 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
}
}
@@ -530,7 +531,7 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
_, exists := accountMap[accountId]
if !exists {
log.BootErrorf("[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)
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
}
}
@@ -539,7 +540,7 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
tagIndex := tagIndexes[i]
if tagIndex.TransactionTime < 1 {
log.BootErrorf("[user_data.CheckTransactionAndAccount] transaction tag index \"id:%d\" does not have transaction time", tagIndex.TagIndexId)
log.CliErrorf(c, "[user_data.CheckTransactionAndAccount] transaction tag index \"id:%d\" does not have transaction time", tagIndex.TagIndexId)
return false, errs.ErrOperationFailed
}
}
@@ -548,23 +549,23 @@ func (l *UserDataCli) CheckTransactionAndAccount(c *cli.Context, username string
}
// FixTransactionTagIndexWithTransactionTime fixes user transaction tag index data with transaction time
func (l *UserDataCli) FixTransactionTagIndexWithTransactionTime(c *cli.Context, username string) (bool, error) {
func (l *UserDataCli) FixTransactionTagIndexWithTransactionTime(c *core.CliContext, username string) (bool, error) {
if username == "" {
log.BootErrorf("[user_data.FixTransactionTagIndexWithTransactionTime] 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.BootErrorf("[user_data.FixTransactionTagIndexWithTransactionTime] error occurs when getting user id by user name")
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] error occurs when getting user id by user name")
return false, err
}
tagIndexes, err := l.tags.GetAllTagIdsOfAllTransactions(nil, uid)
tagIndexes, err := l.tags.GetAllTagIdsOfAllTransactions(c, uid)
if err != nil {
log.BootErrorf("[user_data.FixTransactionTagIndexWithTransactionTime] failed to get tag index for user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] failed to get tag index for user \"%s\", because %s", username, err.Error())
return false, err
}
@@ -579,14 +580,14 @@ func (l *UserDataCli) FixTransactionTagIndexWithTransactionTime(c *cli.Context,
}
if len(invalidTagIndexes) < 1 {
log.BootErrorf("[user_data.FixTransactionTagIndexWithTransactionTime] all user transaction tag index data has been checked, there is no problem with user data")
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(nil, uid, pageCountForGettingTransactions, false)
allTransactions, err := l.transactions.GetAllTransactions(c, uid, pageCountForGettingTransactions, false)
if err != nil {
log.BootErrorf("[user_data.FixTransactionTagIndexWithTransactionTime] failed to all transactions for user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] failed to all transactions for user \"%s\", because %s", username, err.Error())
return false, err
}
@@ -603,10 +604,10 @@ func (l *UserDataCli) FixTransactionTagIndexWithTransactionTime(c *cli.Context,
tagIndex.TransactionTime = transaction.TransactionTime
}
err = l.tags.ModifyTagIndexTransactionTime(nil, uid, invalidTagIndexes)
err = l.tags.ModifyTagIndexTransactionTime(c, uid, invalidTagIndexes)
if err != nil {
log.BootErrorf("[user_data.FixTransactionTagIndexWithTransactionTime] failed to update transaction tag index for user \"%s\", because %s", username, err.Error())
log.CliErrorf(c, "[user_data.FixTransactionTagIndexWithTransactionTime] failed to update transaction tag index for user \"%s\", because %s", username, err.Error())
return false, err
}
@@ -614,99 +615,183 @@ func (l *UserDataCli) FixTransactionTagIndexWithTransactionTime(c *cli.Context,
}
// ExportTransaction returns csv file content according user all transactions
func (l *UserDataCli) ExportTransaction(c *cli.Context, username string, fileType string) ([]byte, error) {
func (l *UserDataCli) ExportTransaction(c *core.CliContext, username string, fileType string) ([]byte, error) {
if username == "" {
log.BootErrorf("[user_data.ExportTransaction] user name is empty")
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, _, tagIndexesMap, 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(nil, 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
}
var dataExporter converters.DataConverter
dataExporter := converters.GetTransactionDataExporter(fileType)
if fileType == "tsv" {
dataExporter = l.ezBookKeepingTsvExporter
} else {
dataExporter = l.ezBookKeepingCsvExporter
if dataExporter == nil {
return nil, errs.ErrNotImplemented
}
result, err := dataExporter.ToExportedContent(uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexesMap)
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, tagIndexes []*models.TransactionTagIndex, tagIndexesMap 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)
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(nil, 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())
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(nil, 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())
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(nil, 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())
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)
tagIndexes, err = l.tags.GetAllTagIdsOfAllTransactions(nil, 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())
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
}
@@ -715,16 +800,52 @@ func (l *UserDataCli) getUserEssentialData(uid int64, username string) (accountM
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
}
@@ -732,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
}
}
@@ -745,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
@@ -758,19 +879,19 @@ 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, allTagIndexesMap map[int64][]int64, tagMap map[int64]*models.TransactionTag) error {
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 {
@@ -782,7 +903,7 @@ func (l *UserDataCli) checkTransactionTag(c *cli.Context, transactionId int64, a
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
}
}
@@ -790,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
}
@@ -798,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
}
@@ -821,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())
}
-11
View File
@@ -1,11 +0,0 @@
package converters
import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// DataConverter defines the structure of data exporter
type DataConverter interface {
// ToExportedContent returns the exported data
ToExportedContent(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)
}
@@ -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)
}
-17
View File
@@ -1,17 +0,0 @@
package converters
import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// EzBookKeepingCSVFileExporter defines the structure of CSV file exporter
type EzBookKeepingCSVFileExporter struct {
EzBookKeepingPlainFileExporter
}
const csvSeparator = ","
// ToExportedContent returns the exported CSV data
func (e *EzBookKeepingCSVFileExporter) ToExportedContent(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) {
return e.toExportedContent(uid, csvSeparator, transactions, accountMap, categoryMap, tagMap, allTagIndexes)
}
-195
View File
@@ -1,195 +0,0 @@
package converters
import (
"fmt"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// EzBookKeepingPlainFileExporter defines the structure of plain file exporter
type EzBookKeepingPlainFileExporter struct {
}
const lineSeparator = "\n"
const geoLocationSeparator = " "
const transactionTagSeparator = ";"
const headerLine = "Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description" + lineSeparator
const dataLineFormat = "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" + lineSeparator
// toExportedContent returns the exported plain data
func (e *EzBookKeepingPlainFileExporter) toExportedContent(uid int64, separator string, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) {
var ret strings.Builder
ret.Grow(len(transactions) * 100)
actualHeaderLine := headerLine
actualDataLineFormat := dataLineFormat
if separator != "," {
actualHeaderLine = strings.Replace(headerLine, ",", separator, -1)
actualDataLineFormat = strings.Replace(dataLineFormat, ",", separator, -1)
}
ret.WriteString(actualHeaderLine)
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.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone)
transactionTimezone := utils.FormatTimezoneOffset(transactionTimeZone)
transactionType := e.getTransactionTypeName(transaction.Type)
category := e.replaceDelimiters(e.getTransactionCategoryName(transaction.CategoryId, categoryMap), separator)
subCategory := e.replaceDelimiters(e.getTransactionSubCategoryName(transaction.CategoryId, categoryMap), separator)
account := e.replaceDelimiters(e.getAccountName(transaction.AccountId, accountMap), separator)
accountCurrency := e.getAccountCurrency(transaction.AccountId, accountMap)
amount := e.getDisplayAmount(transaction.Amount)
account2 := ""
account2Currency := ""
account2Amount := ""
geoLocation := ""
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2 = e.replaceDelimiters(e.getAccountName(transaction.RelatedAccountId, accountMap), separator)
account2Currency = e.getAccountCurrency(transaction.RelatedAccountId, accountMap)
account2Amount = e.getDisplayAmount(transaction.RelatedAccountAmount)
}
if transaction.GeoLongitude != 0 || transaction.GeoLatitude != 0 {
geoLocation = fmt.Sprintf("%f%s%f", transaction.GeoLongitude, geoLocationSeparator, transaction.GeoLatitude)
}
tags := e.replaceDelimiters(e.getTags(transaction.TransactionId, allTagIndexes, tagMap), separator)
comment := e.replaceDelimiters(transaction.Comment, separator)
ret.WriteString(fmt.Sprintf(actualDataLineFormat, transactionTime, transactionTimezone, transactionType, category, subCategory, account, accountCurrency, amount, account2, account2Currency, account2Amount, geoLocation, tags, comment))
}
return []byte(ret.String()), nil
}
func (e *EzBookKeepingPlainFileExporter) 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 *EzBookKeepingPlainFileExporter) 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 *EzBookKeepingPlainFileExporter) getTransactionSubCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if exists {
return category.Name
} else {
return ""
}
}
func (e *EzBookKeepingPlainFileExporter) getAccountName(accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return account.Name
} else {
return ""
}
}
func (e *EzBookKeepingPlainFileExporter) getAccountCurrency(accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return account.Currency
} else {
return ""
}
}
func (e *EzBookKeepingPlainFileExporter) 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 *EzBookKeepingPlainFileExporter) getTags(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++ {
if i > 0 {
ret.WriteString(transactionTagSeparator)
}
tagIndex := tagIndexes[i]
tag, exists := tagMap[tagIndex]
if !exists {
continue
}
ret.WriteString(tag.Name)
}
return ret.String()
}
func (e *EzBookKeepingPlainFileExporter) replaceDelimiters(text string, separator string) string {
text = strings.Replace(text, separator, " ", -1)
text = strings.Replace(text, "\r\n", " ", -1)
text = strings.Replace(text, "\n", " ", -1)
return text
}
-17
View File
@@ -1,17 +0,0 @@
package converters
import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// EzBookKeepingTSVFileExporter defines the structure of TSV file exporter
type EzBookKeepingTSVFileExporter struct {
EzBookKeepingPlainFileExporter
}
const tsvSeparator = "\t"
// ToExportedContent returns the exported TSV data
func (e *EzBookKeepingTSVFileExporter) ToExportedContent(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) {
return e.toExportedContent(uid, tsvSeparator, transactions, accountMap, categoryMap, tagMap, allTagIndexes)
}
@@ -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"`
}
+312
View File
@@ -0,0 +1,312 @@
package ofx
import (
"bufio"
"bytes"
"encoding/xml"
"regexp"
"strings"
"golang.org/x/net/html/charset"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/sgml"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const ofxUnicodeEncoding = "unicode"
const ofxUSAsciiEncoding = "usascii"
const ofx1SGMLDataFormat = "OFXSGML"
var ofx2HeaderPattern = regexp.MustCompile("<\\?OFX( +[A-Z]+=\"[^=]*\")* *\\?>")
var ofx2HeaderAttributePattern = regexp.MustCompile(" +([A-Z]+)=\"([^=]*)\"")
// ofxFileReader defines the structure of open financial exchange (ofx) file reader
type ofxFileReader interface {
// read returns the imported open financial exchange (ofx) file
read(ctx core.Context) (*ofxFile, error)
}
// ofxVersion1FileReader defines the structure of open financial exchange (ofx) declaration version 1.x file reader
type ofxVersion1FileReader struct {
fileHeader *ofxFileHeader
sgmlDecoder *sgml.Decoder
}
// ofxVersion2FileReader defines the structure of open financial exchange (ofx) declaration version 2.x file reader
type ofxVersion2FileReader struct {
fileHeader *ofxFileHeader
xmlDecoder *xml.Decoder
}
// read returns the imported open financial exchange (ofx) file
func (r *ofxVersion1FileReader) read(ctx core.Context) (*ofxFile, error) {
file := &ofxFile{}
err := r.sgmlDecoder.Decode(&file)
if err != nil {
log.Errorf(ctx, "[ofxVersion1FileReader.read] cannot read ofx 1.x file, because %s", err.Error())
return nil, errs.ErrInvalidOFXFile
}
file.FileHeader = r.fileHeader
return file, nil
}
// read returns the imported open financial exchange (ofx) file
func (r *ofxVersion2FileReader) read(ctx core.Context) (*ofxFile, error) {
file := &ofxFile{}
err := r.xmlDecoder.Decode(&file)
if err != nil {
log.Errorf(ctx, "[ofxVersion2FileReader.read] cannot read ofx 2.x file, because %s", err.Error())
return nil, errs.ErrInvalidOFXFile
}
file.FileHeader = r.fileHeader
return file, nil
}
func createNewOFXFileReader(ctx core.Context, data []byte) (ofxFileReader, error) {
firstNonCrLfIndex := 0
for i := 0; i < len(data); i++ {
if data[i] != '\n' && data[i] != '\r' {
firstNonCrLfIndex = i
break
}
}
if len(data) > 5 && string(data[firstNonCrLfIndex:firstNonCrLfIndex+5]) == "<?xml" { // ofx 2.x starts with <?xml
return createNewOFX2FileReader(ctx, data, true)
} else if len(data) > 10 && string(data[firstNonCrLfIndex:firstNonCrLfIndex+10]) == "OFXHEADER:" { // ofx 1.x starts with OFXHEADER:
return createNewOFX1FileReader(ctx, data)
} else if len(data) > 5 && string(data[firstNonCrLfIndex:firstNonCrLfIndex+5]) == "<OFX>" { // no ofx header
return createNewOFX2FileReader(ctx, data, false)
}
return nil, errs.ErrInvalidOFXFile
}
func createNewOFX1FileReader(ctx core.Context, data []byte) (ofxFileReader, error) {
fileHeader, fileData, dataType, enc, err := readOFX1FileHeader(ctx, data)
if err != nil {
return nil, err
}
if fileHeader.OFXDeclarationVersion != ofxVersion1 {
log.Errorf(ctx, "[ofx_data_reader.createNewOFX1FileReader] cannot parse ofx 1.x file header, because declaration version is \"%s\"", fileHeader.OFXDeclarationVersion)
return nil, errs.ErrInvalidOFXFile
}
if dataType != ofx1SGMLDataFormat {
log.Errorf(ctx, "[ofx_data_reader.createNewOFX1FileReader] cannot parse ofx 1.x file header, because data type is \"%s\"", dataType)
return nil, errs.ErrInvalidOFXFile
}
reader := bytes.NewReader(fileData)
buffer := &bytes.Buffer{}
if enc != nil {
transformReader := transform.NewReader(reader, enc.NewDecoder())
_, err = buffer.ReadFrom(transformReader)
} else {
_, err = buffer.ReadFrom(reader)
}
if err != nil {
log.Errorf(ctx, "[ofx_data_reader.createNewOFX1FileReader] cannot read ofx 1.x file content, because %s", err.Error())
return nil, errs.ErrInvalidOFXFile
}
sgmlData := buffer.String()
stringReader := strings.NewReader(sgmlData)
sgmlDecoder := sgml.NewDecoder(stringReader)
return &ofxVersion1FileReader{
fileHeader: fileHeader,
sgmlDecoder: sgmlDecoder,
}, nil
}
func createNewOFX2FileReader(ctx core.Context, data []byte, withHeader bool) (ofxFileReader, error) {
var fileHeader *ofxFileHeader = nil
var err error
if withHeader {
fileHeader, err = readOFX2FileHeader(ctx, data)
if err != nil {
return nil, err
}
if fileHeader.OFXDeclarationVersion != ofxVersion2 {
log.Errorf(ctx, "[ofx_data_reader.createNewOFX2FileReader] cannot parse ofx 2.x file header, because declaration version is \"%s\"", fileHeader.OFXDeclarationVersion)
return nil, errs.ErrInvalidOFXFile
}
}
xmlDecoder := xml.NewDecoder(bytes.NewReader(data))
xmlDecoder.CharsetReader = charset.NewReaderLabel
return &ofxVersion2FileReader{
fileHeader: fileHeader,
xmlDecoder: xmlDecoder,
}, nil
}
func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeader, fileData []byte, dataType string, enc encoding.Encoding, err error) {
fileHeader = &ofxFileHeader{}
dataType = ""
fileEncoding := ""
fileCharset := ""
fileDataStartPosition := 0
lastCrLf := -1
for i := 0; i < len(data); i++ {
if data[i] != '\n' && data[i] != '\r' {
continue
}
if lastCrLf == i-1 {
lastCrLf = i
continue
}
line := string(data[lastCrLf+1 : i])
if strings.Index(line, "<OFX>") == 0 {
fileDataStartPosition = lastCrLf + 1
break
}
lastCrLf = i
if line == "" {
continue
}
items := strings.Split(line, ":")
if len(items) != 2 {
log.Warnf(ctx, "[ofx_data_reader.readOFX1FileHeader] cannot parse line in ofx 1.x file header, because line is \"%s\"", line)
continue
}
key := items[0]
value := items[1]
if key == "OFXHEADER" {
fileHeader.OFXDeclarationVersion = oFXDeclarationVersion(value)
} else if key == "DATA" {
dataType = value
} else if key == "VERSION" {
fileHeader.OFXDataVersion = value
} else if key == "SECURITY" {
fileHeader.Security = value
} else if key == "ENCODING" {
fileEncoding = strings.ToLower(value)
} else if key == "CHARSET" {
fileCharset = strings.ToLower(value)
} else if key == "COMPRESSION" {
continue // ignore
} else if key == "OLDFILEUID" {
fileHeader.OldFileUid = value
} else if key == "NEWFILEUID" {
fileHeader.NewFileUid = value
} else {
log.Warnf(ctx, "[ofx_data_reader.readOFX1FileHeader] cannot parse unknown header line in ofx 1.x file header, because line is \"%s\"", line)
continue
}
}
if fileEncoding == ofxUSAsciiEncoding {
if utils.IsStringOnlyContainsDigits(fileCharset) {
fileCharset = "cp" + fileCharset
}
enc, _ = charset.Lookup(fileCharset)
if enc == nil {
enc, _ = charset.Lookup("us-ascii")
}
if enc == nil {
enc = charmap.Windows1252
}
} else if fileEncoding == ofxUnicodeEncoding {
enc, _ = charset.Lookup(ofxUnicodeEncoding)
if enc == nil {
enc = unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)
}
} else {
log.Errorf(ctx, "[ofx_data_reader.readOFX1FileHeader] cannot parse ofx 1.x file, because encoding \"%s\" is unknown", fileEncoding)
return nil, nil, "", nil, errs.ErrInvalidOFXFile
}
return fileHeader, data[fileDataStartPosition:], dataType, enc, nil
}
func readOFX2FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeader, err error) {
reader := bytes.NewReader(data)
scanner := bufio.NewScanner(reader)
fileHeader = &ofxFileHeader{}
headerLine := ""
for scanner.Scan() {
line := scanner.Text()
ofxHeaderStartIndex := strings.Index(line, "<?OFX ")
if ofxHeaderStartIndex >= 0 {
headerLine = ofx2HeaderPattern.FindString(line)
break
}
}
if headerLine == "" {
log.Errorf(ctx, "[ofx_data_reader.readOFX2FileHeader] cannot find ofx 2.x file header")
return nil, errs.ErrInvalidOFXFile
}
headerAttributes := ofx2HeaderAttributePattern.FindAllStringSubmatch(headerLine, -1)
for _, attributeItems := range headerAttributes {
if len(attributeItems) != 3 {
log.Warnf(ctx, "[ofx_data_reader.readOFX2FileHeader] cannot parse line in ofx 2.x file header, because item is \"%s\"", attributeItems)
continue
}
name := attributeItems[1]
value := attributeItems[2]
if name == "OFXHEADER" {
fileHeader.OFXDeclarationVersion = oFXDeclarationVersion(value)
} else if name == "VERSION" {
fileHeader.OFXDataVersion = value
} else if name == "SECURITY" {
fileHeader.Security = value
} else if name == "OLDFILEUID" {
fileHeader.OldFileUid = value
} else if name == "NEWFILEUID" {
fileHeader.NewFileUid = value
} else {
log.Warnf(ctx, "[ofx_data_reader.readOFX2FileHeader] cannot parse unknown header line in ofx 2.x file header, because item is \"%s\"", attributeItems)
continue
}
}
return fileHeader, nil
}
+904
View File
@@ -0,0 +1,904 @@
package ofx
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestCreateNewOFXFileReader_OFX1(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"<BANKMSGSRSV1>\n"+
"<STMTTRNRS>\n"+
"<STMTRS>\n"+
"<CURDEF>CNY\n"+
"<BANKACCTFROM>\n"+
"<ACCTID>123\n"+
"</BANKACCTFROM>\n"+
"<BANKTRANLIST>\n"+
"<STMTTRN>\n"+
"<TRNTYPE>DEP\n"+
"<DTPOSTED>20240901012345.000[+8:CST]\n"+
"<TRNAMT>123.45\n"+
"</STMTTRN>\n"+
"</BANKTRANLIST>\n"+
"</STMTRS>\n"+
"</STMTTRNRS>\n"+
"</BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion1, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "103", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.Equal(t, "CNY", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.DefaultCurrency)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
assert.Equal(t, "123", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom.AccountId)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.Equal(t, ofxDepositTransaction, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].TransactionType)
assert.Equal(t, "20240901012345.000[+8:CST]", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].PostedDate)
assert.Equal(t, "123.45", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Amount)
}
func TestCreateNewOFXFileReader_OFX1WithoutBreakLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>"+
"<BANKMSGSRSV1>"+
"<STMTTRNRS>"+
"<STMTRS>"+
"<CURDEF>CNY"+
"<BANKACCTFROM>"+
"<ACCTID>123"+
"</BANKACCTFROM>"+
"<BANKTRANLIST>"+
"<STMTTRN>"+
"<TRNTYPE>DEP"+
"<DTPOSTED>20240901012345.000[+8:CST]"+
"<TRNAMT>123.45"+
"</STMTTRN>"+
"</BANKTRANLIST>"+
"</STMTRS>"+
"</STMTTRNRS>"+
"</BANKMSGSRSV1>"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion1, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "103", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.Equal(t, "CNY", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.DefaultCurrency)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
assert.Equal(t, "123", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom.AccountId)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.Equal(t, ofxDepositTransaction, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].TransactionType)
assert.Equal(t, "20240901012345.000[+8:CST]", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].PostedDate)
assert.Equal(t, "123.45", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Amount)
}
func TestCreateNewOFXFileReader_OFX1ParseBankAccountFrom(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"<BANKMSGSRSV1>\n"+
"<STMTTRNRS>\n"+
"<STMTRS>\n"+
"<BANKACCTFROM>\n"+
"<BANKID>1234567890\n"+
"<BRANCHID>2345678901\n"+
"<ACCTID>3456789012\n"+
"<ACCTTYPE>CHECKING\n"+
"<ACCTKEY>4567890123\n"+
"</BANKACCTFROM>\n"+
"</STMTRS>\n"+
"</STMTTRNRS>\n"+
"</BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
account := ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom
assert.Equal(t, "1234567890", account.BankId)
assert.Equal(t, "2345678901", account.BranchId)
assert.Equal(t, "3456789012", account.AccountId)
assert.Equal(t, ofxCheckingAccount, account.AccountType)
assert.Equal(t, "4567890123", account.AccountKey)
}
func TestCreateNewOFXFileReader_OFX1ParseCreditCardAccountFrom(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"<CREDITCARDMSGSRSV1>\n"+
"<CCSTMTTRNRS>\n"+
"<CCSTMTRS>\n"+
"<CCACCTFROM>\n"+
"<ACCTID>3456789012\n"+
"<ACCTKEY>4567890123\n"+
"</CCACCTFROM>\n"+
"</CCSTMTRS>\n"+
"</CCSTMTTRNRS>\n"+
"</CREDITCARDMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.CreditCardMessageResponseV1)
assert.NotNil(t, ofxFile.CreditCardMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.NotNil(t, ofxFile.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
account := ofxFile.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom
assert.Equal(t, "3456789012", account.AccountId)
assert.Equal(t, "4567890123", account.AccountKey)
}
func TestCreateNewOFXFileReader_OFX1ParseBankTransactionList(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"<BANKMSGSRSV1>\n"+
"<STMTTRNRS>\n"+
"<STMTRS>\n"+
"<BANKTRANLIST>\n"+
"<DTSTART>20240901012345.000[+8:CST]\n"+
"<DTEND>20240901235959.000[+8:CST]\n"+
"</BANKTRANLIST>\n"+
"</STMTRS>\n"+
"</STMTTRNRS>\n"+
"</BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList)
transactionList := ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList
assert.Equal(t, "20240901012345.000[+8:CST]", transactionList.StartDate)
assert.Equal(t, "20240901235959.000[+8:CST]", transactionList.EndDate)
}
func TestCreateNewOFXFileReader_OFX1ParseCreditTransactionList(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"<CREDITCARDMSGSRSV1>\n"+
"<CCSTMTTRNRS>\n"+
"<CCSTMTRS>\n"+
"<BANKTRANLIST>\n"+
"<DTSTART>20240901012345.000[+8:CST]\n"+
"<DTEND>20240901235959.000[+8:CST]\n"+
"</BANKTRANLIST>\n"+
"</CCSTMTRS>\n"+
"</CCSTMTTRNRS>\n"+
"</CREDITCARDMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.CreditCardMessageResponseV1)
assert.NotNil(t, ofxFile.CreditCardMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.NotNil(t, ofxFile.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList)
transactionList := ofxFile.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList
assert.Equal(t, "20240901012345.000[+8:CST]", transactionList.StartDate)
assert.Equal(t, "20240901235959.000[+8:CST]", transactionList.EndDate)
}
func TestCreateNewOFXFileReader_OFX1ParseTransaction(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"<BANKMSGSRSV1>\n"+
"<STMTTRNRS>\n"+
"<STMTRS>\n"+
"<BANKACCTFROM>\n"+
"<ACCTID>123\n"+
"</BANKACCTFROM>\n"+
"<BANKTRANLIST>\n"+
"<STMTTRN>\n"+
"<FITID>1234567890\n"+
"<TRNTYPE>CASH\n"+
"<DTPOSTED>20240901012345.000[+8:CST]\n"+
"<TRNAMT>123.45\n"+
"<NAME>Test Name\n"+
"<MEMO>Some Text\n"+
"<CURRENCY>CNY\n"+
"<ORIGCURRENCY>USD\n"+
"</STMTTRN>\n"+
"</BANKTRANLIST>\n"+
"</STMTRS>\n"+
"</STMTTRNRS>\n"+
"</BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0])
transaction := ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0]
assert.Equal(t, "1234567890", transaction.TransactionId)
assert.Equal(t, ofxCashWithdrawalTransaction, transaction.TransactionType)
assert.Equal(t, "20240901012345.000[+8:CST]", transaction.PostedDate)
assert.Equal(t, "123.45", transaction.Amount)
assert.Equal(t, "Test Name", transaction.Name)
assert.Equal(t, "Some Text", transaction.Memo)
assert.Equal(t, "CNY", transaction.Currency)
assert.Equal(t, "USD", transaction.OriginalCurrency)
}
func TestCreateNewOFXFileReader_OFX1ParseTransactionPayee(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"<BANKMSGSRSV1>\n"+
"<STMTTRNRS>\n"+
"<STMTRS>\n"+
"<BANKACCTFROM>\n"+
"<ACCTID>123\n"+
"</BANKACCTFROM>\n"+
"<BANKTRANLIST>\n"+
"<STMTTRN>\n"+
"<TRNTYPE>DEP\n"+
"<DTPOSTED>20240901012345.000[+8:CST]\n"+
"<TRNAMT>123.45\n"+
"<PAYEE>\n"+
"<NAME>Test Name\n"+
"<ADDR1>Address 1\n"+
"<ADDR2>Address 2\n"+
"<ADDR3>Address 3\n"+
"<CITY>City Name\n"+
"<STATE>State Name\n"+
"<POSTALCODE>10000000\n"+
"<COUNTRY>Country Name\n"+
"<PHONE>11111111111\n"+
"</PAYEE>\n"+
"</STMTTRN>\n"+
"</BANKTRANLIST>\n"+
"</STMTRS>\n"+
"</STMTTRNRS>\n"+
"</BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0])
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Payee)
payee := ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Payee
assert.Equal(t, "Test Name", payee.Name)
assert.Equal(t, "Address 1", payee.Address1)
assert.Equal(t, "Address 2", payee.Address2)
assert.Equal(t, "Address 3", payee.Address3)
assert.Equal(t, "City Name", payee.City)
assert.Equal(t, "State Name", payee.State)
assert.Equal(t, "10000000", payee.PostalCode)
assert.Equal(t, "Country Name", payee.Country)
assert.Equal(t, "11111111111", payee.Phone)
}
func TestCreateNewOFXFileReader_OFX1WithEndElement(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"<BANKMSGSRSV1>\n"+
"<STMTTRNRS>\n"+
"<STMTRS>\n"+
"<CURDEF>CNY</CURDEF>\n"+
"<BANKACCTFROM>\n"+
"<ACCTID>123</ACCTID>\n"+
"</BANKACCTFROM>\n"+
"<BANKTRANLIST>\n"+
"<STMTTRN>\n"+
"<TRNTYPE>DEP</TRNTYPE>\n"+
"<DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
"<TRNAMT>123.45</TRNAMT>\n"+
"</STMTTRN>\n"+
"</BANKTRANLIST>\n"+
"</STMTRS>\n"+
"</STMTTRNRS>\n"+
"</BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion1, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "103", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.Equal(t, "CNY", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.DefaultCurrency)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
assert.Equal(t, "123", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom.AccountId)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.Equal(t, ofxDepositTransaction, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].TransactionType)
assert.Equal(t, "20240901012345.000[+8:CST]", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].PostedDate)
assert.Equal(t, "123.45", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Amount)
}
func TestCreateNewOFXFileReader_OFX1WithBlanklinesInHeader(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"\n"+
"\n"+
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>"+
"<BANKMSGSRSV1>"+
"<STMTTRNRS>"+
"<STMTRS>"+
"<CURDEF>CNY"+
"<BANKACCTFROM>"+
"<ACCTID>123"+
"</BANKACCTFROM>"+
"<BANKTRANLIST>"+
"<STMTTRN>"+
"<TRNTYPE>DEP"+
"<DTPOSTED>20240901012345.000[+8:CST]"+
"<TRNAMT>123.45"+
"</STMTTRN>"+
"</BANKTRANLIST>"+
"</STMTRS>"+
"</STMTTRNRS>"+
"</BANKMSGSRSV1>"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion1, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "103", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.Equal(t, "CNY", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.DefaultCurrency)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
assert.Equal(t, "123", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom.AccountId)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.Equal(t, ofxDepositTransaction, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].TransactionType)
assert.Equal(t, "20240901012345.000[+8:CST]", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].PostedDate)
assert.Equal(t, "123.45", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Amount)
}
func TestCreateNewOFXFileReader_OFX1WithoutCharset(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"FOO:BAR\n"+
"\n"+
"<OFX>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion1, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "103", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
}
func TestCreateNewOFXFileReader_OFX1WithInvalidHeaderVersion(t *testing.T) {
context := core.NewNullContext()
_, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:200\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"</OFX>"))
assert.EqualError(t, err, errs.ErrInvalidOFXFile.Message)
}
func TestCreateNewOFXFileReader_OFX1WithInvalidHeader(t *testing.T) {
context := core.NewNullContext()
_, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:XML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"\n"+
"<OFX>\n"+
"</OFX>"))
assert.EqualError(t, err, errs.ErrInvalidOFXFile.Message)
}
func TestCreateNewOFXFileReader_OFX1WithUnknownHeader(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"OFXHEADER:100\n"+
"DATA:OFXSGML\n"+
"VERSION:103\n"+
"SECURITY:NONE\n"+
"ENCODING:USASCII\n"+
"CHARSET:1252\n"+
"COMPRESSION:NONE\n"+
"OLDFILEUID:NONE\n"+
"NEWFILEUID:NONE\n"+
"FOO:BAR\n"+
"\n"+
"<OFX>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion1, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "103", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
}
func TestCreateNewOFXFileReader_OFX2(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>\n"+
"<?OFX OFXHEADER=\"200\" VERSION=\"211\" SECURITY=\"NONE\" OLDFILEUID=\"NONE\" NEWFILEUID=\"NONE\"?>\n"+
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion2, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "211", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.Equal(t, "CNY", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.DefaultCurrency)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
assert.Equal(t, "123", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom.AccountId)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.Equal(t, ofxDepositTransaction, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].TransactionType)
assert.Equal(t, "20240901012345.000[+8:CST]", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].PostedDate)
assert.Equal(t, "123.45", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Amount)
}
func TestCreateNewOFXFileReader_OFX2WithoutBreakLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>"+
"<?OFX OFXHEADER=\"200\" VERSION=\"211\" SECURITY=\"NONE\" OLDFILEUID=\"NONE\" NEWFILEUID=\"NONE\"?>"+
"<OFX>"+
" <BANKMSGSRSV1>"+
" <STMTTRNRS>"+
" <STMTRS>"+
" <CURDEF>CNY</CURDEF>"+
" <BANKACCTFROM>"+
" <ACCTID>123</ACCTID>"+
" </BANKACCTFROM>"+
" <BANKTRANLIST>"+
" <STMTTRN>"+
" <TRNTYPE>DEP</TRNTYPE>"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>"+
" <TRNAMT>123.45</TRNAMT>"+
" </STMTTRN>"+
" </BANKTRANLIST>"+
" </STMTRS>"+
" </STMTTRNRS>"+
" </BANKMSGSRSV1>"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion2, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "211", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.Equal(t, "CNY", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.DefaultCurrency)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
assert.Equal(t, "123", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom.AccountId)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.Equal(t, ofxDepositTransaction, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].TransactionType)
assert.Equal(t, "20240901012345.000[+8:CST]", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].PostedDate)
assert.Equal(t, "123.45", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Amount)
}
func TestCreateNewOFXFileReader_OFX2WithoutOFXHeader(t *testing.T) {
context := core.NewNullContext()
_, err := createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>"+
"<OFX>"+
"</OFX>"))
assert.EqualError(t, err, errs.ErrInvalidOFXFile.Message)
}
func TestCreateNewOFXFileReader_OFX2WithInvalidHeaderVersion(t *testing.T) {
context := core.NewNullContext()
_, err := createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>"+
"<?OFX OFXHEADER=\"100\" VERSION=\"211\" SECURITY=\"NONE\" OLDFILEUID=\"NONE\" NEWFILEUID=\"NONE\"?>"+
"<OFX>"+
"</OFX>"))
assert.EqualError(t, err, errs.ErrInvalidOFXFile.Message)
}
func TestCreateNewOFXFileReader_OFX2WithInvalidHeader(t *testing.T) {
context := core.NewNullContext()
_, err := createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>"+
"<?OFX?>"+
"<OFX>"+
"</OFX>"))
assert.EqualError(t, err, errs.ErrInvalidOFXFile.Message)
_, err = createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>"+
"<?OFX OFXHEADER=200?>"+
"<OFX>"+
"</OFX>"))
assert.EqualError(t, err, errs.ErrInvalidOFXFile.Message)
_, err = createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>"+
"<?OFX OFXHEADER=\"200\" VERSION=\"211\" SECURITY=\"NONE\" OLDFILEUID=\"NONE\" NEWFILEUID=\"NONE\" test=\"\"?>"+
"<OFX>"+
"</OFX>"))
assert.EqualError(t, err, errs.ErrInvalidOFXFile.Message)
}
func TestCreateNewOFXFileReader_OFX2WithUnknownHeader(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>"+
"<?OFX OFXHEADER=\"200\" VERSION=\"211\" SECURITY=\"NONE\" OLDFILEUID=\"NONE\" NEWFILEUID=\"NONE\" FOO=\"BAR\"?>"+
"<OFX>"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.NotNil(t, ofxFile)
assert.NotNil(t, ofxFile.FileHeader)
assert.Equal(t, ofxVersion2, ofxFile.FileHeader.OFXDeclarationVersion)
assert.Equal(t, "211", ofxFile.FileHeader.OFXDataVersion)
assert.Equal(t, "NONE", ofxFile.FileHeader.Security)
assert.Equal(t, "NONE", ofxFile.FileHeader.OldFileUid)
assert.Equal(t, "NONE", ofxFile.FileHeader.NewFileUid)
}
func TestCreateNewOFXFileReader_OFX2WithSGML(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"<?xml version=\"1.0\" encoding=\"US-ASCII\"?>\n"+
"<?OFX OFXHEADER=\"200\" VERSION=\"211\" SECURITY=\"NONE\" OLDFILEUID=\"NONE\" NEWFILEUID=\"NONE\"?>\n"+
"<BANKMSGSRSV1>\n"+
"<STMTTRNRS>\n"+
"<STMTRS>\n"+
"<CURDEF>CNY\n"+
"<BANKACCTFROM>\n"+
"<ACCTID>123\n"+
"</BANKACCTFROM>\n"+
"<BANKTRANLIST>\n"+
"<STMTTRN>\n"+
"<TRNTYPE>DEP\n"+
"<DTPOSTED>20240901012345.000[+8:CST]\n"+
"<TRNAMT>123.45\n"+
"</STMTTRN>\n"+
"</BANKTRANLIST>\n"+
"</STMTRS>\n"+
"</STMTTRNRS>\n"+
"</BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidOFXFile.Message)
}
func TestCreateNewOFXFileReader_OFX2WithoutAnyHeader(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewOFXFileReader(context, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"))
assert.Nil(t, err)
ofxFile, err := reader.read(context)
assert.Nil(t, err)
assert.NotNil(t, ofxFile)
assert.Nil(t, ofxFile.FileHeader)
assert.NotNil(t, ofxFile.BankMessageResponseV1)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse)
assert.Equal(t, "CNY", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.DefaultCurrency)
assert.NotNil(t, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom)
assert.Equal(t, "123", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.AccountFrom.AccountId)
assert.Equal(t, 1, len(ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions))
assert.Equal(t, ofxDepositTransaction, ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].TransactionType)
assert.Equal(t, "20240901012345.000[+8:CST]", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].PostedDate)
assert.Equal(t, "123.45", ofxFile.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList.StatementTransactions[0].Amount)
}
@@ -0,0 +1,48 @@
package ofx
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 ofxTransactionTypeNameMapping = map[models.TransactionType]string{
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)),
}
// ofxTransactionDataImporter defines the structure of open financial exchange (ofx) file importer for transaction data
type ofxTransactionDataImporter struct {
}
// Initialize a open financial exchange (ofx) transaction data importer singleton instance
var (
OFXTransactionDataImporter = &ofxTransactionDataImporter{}
)
// ParseImportedData returns the imported data by parsing the open financial exchange (ofx) file transaction data
func (c *ofxTransactionDataImporter) 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) {
ofxDataReader, err := createNewOFXFileReader(ctx, data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
ofxFile, err := ofxDataReader.read(ctx)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable, err := createNewOFXTransactionDataTable(ofxFile)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := datatable.CreateNewSimpleImporter(ofxTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,741 @@
package ofx
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 TestOFXTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>CHECK</TRNTYPE>\n"+
" <DTPOSTED>20240901123456.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>-0.12</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>XFER</TRNTYPE>\n"+
" <DTPOSTED>20240901225959.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>-1.00</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>XFER</TRNTYPE>\n"+
" <DTPOSTED>20240901235959.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>2.00</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
" <CREDITCARDMSGSRSV1>\n"+
" <CCSTMTTRNRS>\n"+
" <CCSTMTRS>\n"+
" <CURDEF>USD</CURDEF>\n"+
" <CCACCTFROM>\n"+
" <ACCTID>456</ACCTID>\n"+
" </CCACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>ATM</TRNTYPE>\n"+
" <DTPOSTED>20240902012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>1.23</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>POS</TRNTYPE>\n"+
" <DTPOSTED>20240902123456.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>-0.01</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </CCSTMTRS>\n"+
" </CCSTMTTRNRS>\n"+
" </CREDITCARDMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 6, len(allNewTransactions))
assert.Equal(t, 3, 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, int64(1725125025), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "123", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "CNY", allNewTransactions[0].OriginalSourceAccountCurrency)
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, int64(1725165296), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "123", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "CNY", allNewTransactions[1].OriginalSourceAccountCurrency)
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, int64(1725202799), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), allNewTransactions[2].Amount)
assert.Equal(t, "123", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "", 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, int64(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(200), allNewTransactions[3].Amount)
assert.Equal(t, "", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "123", 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(1725211425), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
assert.Equal(t, int64(123), allNewTransactions[4].Amount)
assert.Equal(t, "", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "USD", allNewTransactions[4].OriginalSourceAccountCurrency)
assert.Equal(t, "456", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "USD", allNewTransactions[4].OriginalDestinationAccountCurrency)
assert.Equal(t, "", allNewTransactions[4].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[5].Type)
assert.Equal(t, int64(1725251696), utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime))
assert.Equal(t, int64(1), allNewTransactions[5].Amount)
assert.Equal(t, "456", allNewTransactions[5].OriginalSourceAccountName)
assert.Equal(t, "USD", allNewTransactions[5].OriginalSourceAccountCurrency)
assert.Equal(t, "", allNewTransactions[5].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "123", 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, "456", allNewAccounts[2].Name)
assert.Equal(t, "USD", allNewAccounts[2].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 TestOFXTransactionDataFileParseImportedData_ParseAccountTo(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>XFER</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>-123.45</TRNAMT>\n"+
" <BANKACCTTO>\n"+
" <ACCTID>456</ACCTID>\n"+
" </BANKACCTTO>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
" <CREDITCARDMSGSRSV1>\n"+
" <CCSTMTTRNRS>\n"+
" <CCSTMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <CCACCTFROM>\n"+
" <ACCTID>456</ACCTID>\n"+
" </CCACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>XFER</TRNTYPE>\n"+
" <DTPOSTED>20240902012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>-1.23</TRNAMT>\n"+
" <CCACCTTO>\n"+
" <ACCTID>789</ACCTID>\n"+
" </CCACCTTO>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </CCSTMTRS>\n"+
" </CCSTMTTRNRS>\n"+
" </CREDITCARDMSGSRSV1>\n"+
"</OFX>"), 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(12345), allNewTransactions[0].Amount)
assert.Equal(t, "123", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "CNY", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, "456", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, "CNY", allNewTransactions[0].OriginalDestinationAccountCurrency)
assert.Equal(t, int64(123), allNewTransactions[1].Amount)
assert.Equal(t, "456", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "CNY", allNewTransactions[1].OriginalSourceAccountCurrency)
assert.Equal(t, "789", allNewTransactions[1].OriginalDestinationAccountName)
assert.Equal(t, "CNY", allNewTransactions[1].OriginalDestinationAccountCurrency)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "123", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "456", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
assert.Equal(t, "789", allNewAccounts[2].Name)
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
}
func TestOFXTransactionDataFileParseImportedData_ParseValidTransactionTime(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901123456</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901123456.789</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901125959.000[-3]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901122959.000[-3.5]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240902030405.000[0]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 6, len(allNewTransactions))
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
assert.Equal(t, int64(1725246245), utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime))
}
func TestOFXTransactionDataFileParseImportedData_ParseInvalidTransactionTime(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>2024</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>2024-09-01</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>202491</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901 12:34:56</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestOFXTransactionDataFileParseImportedData_ParseAmount_CommaAsDecimalPoint(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123,45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
}
func TestOFXTransactionDataFileParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123 45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestOFXTransactionDataFileParseImportedData_ParseTransactionCurrency(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" <CURRENCY>USD</CURRENCY>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
}
func TestOFXTransactionDataFileParseImportedData_ParseDescription(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" <NAME>Test</NAME>\n"+
" <MEMO>foo bar\t#test</MEMO>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 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(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" <NAME>Test</NAME>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test", allNewTransactions[0].Comment)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" <PAYEE>\n"+
" <NAME>Test</NAME>\n"+
" </PAYEE>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test", allNewTransactions[0].Comment)
}
func TestOFXTransactionDataFileParseImportedData_MissingAccountFromNode(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Posted Date Node
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingAccountData.Message)
}
func TestOFXTransactionDataFileParseImportedData_MissingCurrencyNode(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Default Currency Node
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestOFXTransactionDataFileParseImportedData_MissingTransactionRequiredNode(t *testing.T) {
converter := OFXTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Posted Date Node
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingTransactionTime.Message)
// Missing Transaction Type Node
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" <TRNAMT>123.45</TRNAMT>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
// Missing Amount Node
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"<OFX>\n"+
" <BANKMSGSRSV1>\n"+
" <STMTTRNRS>\n"+
" <STMTRS>\n"+
" <CURDEF>CNY</CURDEF>\n"+
" <BANKACCTFROM>\n"+
" <ACCTID>123</ACCTID>\n"+
" </BANKACCTFROM>\n"+
" <BANKTRANLIST>\n"+
" <STMTTRN>\n"+
" <TRNTYPE>DEP</TRNTYPE>\n"+
" <DTPOSTED>20240901012345.000[+8:CST]</DTPOSTED>\n"+
" </STMTTRN>\n"+
" </BANKTRANLIST>\n"+
" </STMTRS>\n"+
" </STMTTRNRS>\n"+
" </BANKMSGSRSV1>\n"+
"</OFX>"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
+343
View File
@@ -0,0 +1,343 @@
package ofx
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"
)
var ofxTransactionSupportedColumns = 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_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,
}
// ofxTransactionData defines the structure of open financial exchange (ofx) transaction data
type ofxTransactionData struct {
ofxBaseStatementTransaction
DefaultCurrency string
FromAccountId string
FromCreditAccount bool
ToAccountId string
}
// ofxTransactionDataTable defines the structure of open financial exchange (ofx) transaction data table
type ofxTransactionDataTable struct {
allData []*ofxTransactionData
}
// ofxTransactionDataRow defines the structure of open financial exchange (ofx) transaction data row
type ofxTransactionDataRow struct {
dataTable *ofxTransactionDataTable
data *ofxTransactionData
finalItems map[datatable.TransactionDataTableColumn]string
}
// ofxTransactionDataRowIterator defines the structure of open financial exchange (ofx) transaction data row iterator
type ofxTransactionDataRowIterator struct {
dataTable *ofxTransactionDataTable
currentIndex int
}
// HasColumn returns whether the transaction data table has specified column
func (t *ofxTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
_, exists := ofxTransactionSupportedColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *ofxTransactionDataTable) TransactionRowCount() int {
return len(t.allData)
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *ofxTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &ofxTransactionDataRowIterator{
dataTable: t,
currentIndex: -1,
}
}
// IsValid returns whether this row is valid data for importing
func (r *ofxTransactionDataRow) IsValid() bool {
return true
}
// GetData returns the data in the specified column type
func (r *ofxTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
_, exists := ofxTransactionSupportedColumns[column]
if exists {
return r.finalItems[column]
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *ofxTransactionDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allData)
}
// Next returns the next imported data row
func (t *ofxTransactionDataRowIterator) 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, err := t.parseTransaction(ctx, user, data)
if err != nil {
return nil, err
}
return &ofxTransactionDataRow{
dataTable: t.dataTable,
data: data,
finalItems: rowItems,
}, nil
}
func (t *ofxTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, ofxTransaction *ofxTransactionData) (map[datatable.TransactionDataTableColumn]string, error) {
data := make(map[datatable.TransactionDataTableColumn]string, len(ofxTransactionSupportedColumns))
if ofxTransaction.PostedDate == "" {
return nil, errs.ErrMissingTransactionTime
}
datetime, timezone, err := t.parseTransactionTimeAndTimeZone(ctx, ofxTransaction.PostedDate)
if err != nil {
return nil, err
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = datetime
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = timezone
if ofxTransaction.TransactionType == "" {
return nil, errs.ErrTransactionTypeInvalid
}
if ofxTransaction.FromAccountId == "" {
return nil, errs.ErrMissingAccountData
}
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ofxTransaction.FromAccountId
if ofxTransaction.Currency != "" {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = ofxTransaction.Currency
} else {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = ofxTransaction.DefaultCurrency
}
if ofxTransaction.Amount == "" {
return nil, errs.ErrAmountInvalid
}
amount, err := utils.ParseAmount(strings.ReplaceAll(ofxTransaction.Amount, ",", ".")) // ofx supports decimal point or comma to indicate the start of the fractional amount
if err != nil {
return nil, errs.ErrAmountInvalid
}
if transactionType, exists := ofxTransactionTypeMapping[ofxTransaction.TransactionType]; exists { // known transaction type
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(transactionType))
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] { // income
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
} else if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] { // expense
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
} else { // transfer
if amount >= 0 { // transfer in
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = data[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
} else { // transfer out
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ofxTransaction.ToAccountId
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = data[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
}
}
} else { // transaction type depends on signage of amount
if amount >= 0 { // income
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
} else { // expense
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
}
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] != ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
if ofxTransaction.FromCreditAccount || ofxTransaction.TransactionType == ofxGenericCreditTransaction {
if amount >= 0 { // payment
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = data[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
} else { // purchase
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
}
}
if ofxTransaction.Memo != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ofxTransaction.Memo
} else if ofxTransaction.Name != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ofxTransaction.Name
} else if ofxTransaction.Payee != nil {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ofxTransaction.Payee.Name
} else {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
}
return data, nil
}
func (t *ofxTransactionDataRowIterator) parseTransactionTimeAndTimeZone(ctx core.Context, datetime string) (string, string, error) {
if len(datetime) < 8 {
return "", "", errs.ErrTransactionTimeInvalid
}
var err error
var year, month, day string
hour := "00"
minute := "00"
second := "00"
tzOffset := ofxDefaultTimezoneOffset
if len(datetime) >= 8 { // YYYYMMDD
if !utils.IsStringOnlyContainsDigits(datetime[0:8]) {
log.Errorf(ctx, "[ofx_transaction_table.parseTransactionTimeAndTimeZone] cannot parse time \"%s\", because contains non-digit character", datetime)
return "", "", errs.ErrTransactionTimeInvalid
}
year = datetime[0:4]
month = datetime[4:6]
day = datetime[6:8]
}
if len(datetime) >= 14 { // YYYYMMDDHHMMSS
if !utils.IsStringOnlyContainsDigits(datetime[8:14]) {
log.Errorf(ctx, "[ofx_transaction_table.parseTransactionTimeAndTimeZone] cannot parse time \"%s\", because contains non-digit character", datetime)
return "", "", errs.ErrTransactionTimeInvalid
}
hour = datetime[8:10]
minute = datetime[10:12]
second = datetime[12:14]
}
squareBracketStartIndex := strings.Index(datetime, "[")
if squareBracketStartIndex > 0 { // YYYYMMDDHHMMSS.XXX [gmt offset[:tz name]]
timezoneInfo := datetime[squareBracketStartIndex+1 : len(datetime)-1]
timezoneItems := strings.Split(timezoneInfo, ":")
tzOffset, err = utils.FormatTimezoneOffsetFromHoursOffset(timezoneItems[0])
if err != nil {
log.Errorf(ctx, "[ofx_transaction_table.parseTransactionTimeAndTimeZone] cannot parse timezone offset \"%s\", because %s", timezoneInfo, err.Error())
return "", "", errs.ErrTransactionTimeZoneInvalid
}
}
return fmt.Sprintf("%s-%s-%s %s:%s:%s", year, month, day, hour, minute, second), tzOffset, nil
}
func createNewOFXTransactionDataTable(file *ofxFile) (*ofxTransactionDataTable, error) {
if file == nil {
return nil, errs.ErrNotFoundTransactionDataInFile
}
allData := make([]*ofxTransactionData, 0)
if file.BankMessageResponseV1 != nil &&
file.BankMessageResponseV1.StatementTransactionResponse != nil &&
file.BankMessageResponseV1.StatementTransactionResponse.StatementResponse != nil &&
file.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList != nil {
statement := file.BankMessageResponseV1.StatementTransactionResponse.StatementResponse
bankTransactions := statement.TransactionList.StatementTransactions
fromAccountId := ""
fromCreditAccount := false
if statement.AccountFrom != nil {
fromAccountId = statement.AccountFrom.AccountId
if statement.AccountFrom.AccountType == ofxLineOfCreditAccount {
fromCreditAccount = true
}
}
for i := 0; i < len(bankTransactions); i++ {
toAccountId := ""
if bankTransactions[i].AccountTo != nil {
toAccountId = bankTransactions[i].AccountTo.AccountId
}
allData = append(allData, &ofxTransactionData{
ofxBaseStatementTransaction: bankTransactions[i].ofxBaseStatementTransaction,
DefaultCurrency: statement.DefaultCurrency,
FromAccountId: fromAccountId,
FromCreditAccount: fromCreditAccount,
ToAccountId: toAccountId,
})
}
}
if file.CreditCardMessageResponseV1 != nil &&
file.CreditCardMessageResponseV1.StatementTransactionResponse != nil &&
file.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse != nil &&
file.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList != nil {
statement := file.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse
bankTransactions := statement.TransactionList.StatementTransactions
fromAccountId := ""
if statement.AccountFrom != nil {
fromAccountId = statement.AccountFrom.AccountId
}
for i := 0; i < len(bankTransactions); i++ {
toAccountId := ""
if bankTransactions[i].AccountTo != nil {
toAccountId = bankTransactions[i].AccountTo.AccountId
}
allData = append(allData, &ofxTransactionData{
ofxBaseStatementTransaction: bankTransactions[i].ofxBaseStatementTransaction,
DefaultCurrency: statement.DefaultCurrency,
FromAccountId: fromAccountId,
FromCreditAccount: true,
ToAccountId: toAccountId,
})
}
}
return &ofxTransactionDataTable{
allData: allData,
}, nil
}
+124
View File
@@ -0,0 +1,124 @@
package qif
// qifTransactionClearedStatus represents the quicken interchange format (qif) transaction cleared status
type qifTransactionClearedStatus string
// Quicken interchange format transaction types
const (
qifClearedStatusUnreconciled qifTransactionClearedStatus = ""
qifClearedStatusCleared qifTransactionClearedStatus = "C"
qifClearedStatusReconciled qifTransactionClearedStatus = "R"
)
// qifTransactionType represents the quicken interchange format (qif) transaction type
type qifTransactionType string
// Quicken interchange format transaction types
const (
qifInvalidTransactionType qifTransactionType = ""
qifCheckTransactionType qifTransactionType = "KC"
qifDepositTransactionType qifTransactionType = "KD"
qifPaymentTransactionType qifTransactionType = "KP"
qifInvestmentTransactionType qifTransactionType = "KI"
qifElectronicPayeeTransactionType qifTransactionType = "KE"
)
// qifCategoryType represents the quicken interchange format (qif) category type
type qifCategoryType string
// Quicken interchange format category types
const (
qifIncomeTransaction qifCategoryType = "I"
qifExpenseTransaction qifCategoryType = "E"
)
// qifData defines the structure of quicken interchange format (qif) data
type qifData struct {
bankAccountTransactions []*qifTransactionData
cashAccountTransactions []*qifTransactionData
creditCardAccountTransactions []*qifTransactionData
assetAccountTransactions []*qifTransactionData
liabilityAccountTransactions []*qifTransactionData
memorizedTransactions []*qifMemorizedTransactionData
investmentAccountTransactions []*qifInvestmentTransactionData
accounts []*qifAccountData
categories []*qifCategoryData
classes []*qifClassData
}
// qifTransactionData defines the structure of quicken interchange format (qif) transaction data
type qifTransactionData struct {
date string
amount string
clearedStatus qifTransactionClearedStatus
num string
payee string
memo string
addresses []string
category string
subTransactionCategory []string
subTransactionMemo []string
subTransactionAmount []string
account *qifAccountData
}
// qifInvestmentTransactionData defines the structure of quicken interchange format (qif) investment transaction data
type qifInvestmentTransactionData struct {
date string
action string
security string
price string
quantity string
amount string
clearedStatus qifTransactionClearedStatus
text string
memo string
commission string
accountForTransfer string
amountTransferred string
account *qifAccountData
}
// qifMemorizedTransactionData defines the structure of quicken interchange format (qif) memorized transaction data
type qifMemorizedTransactionData struct {
qifTransactionData
transactionType qifTransactionType
amortization qifMemorizedTransactionAmortizationData
}
// qifMemorizedTransactionAmortizationData defines the structure of quicken interchange format (qif) memorized transaction amortization data
type qifMemorizedTransactionAmortizationData struct {
firstPaymentDate string
totalYearsForLoan string
numberOfPayments string
numberOfPeriodsPerYear string
interestRate string
currentLoanBalance string
originalLoanAmount string
}
// qifAccountData defines the structure of quicken interchange format (qif) account data
type qifAccountData struct {
name string
accountType string
description string
creditLimit string
statementBalanceDate string
statementBalanceAmount string
}
// qifCategoryData defines the structure of quicken interchange format (qif) category data
type qifCategoryData struct {
name string
description string
taxRelated bool
categoryType qifCategoryType
budgetAmount string
taxScheduleInformation string
}
// qifClassData defines the structure of quicken interchange format (qif) class data
type qifClassData struct {
name string
description string
}
+481
View File
@@ -0,0 +1,481 @@
package qif
import (
"bufio"
"bytes"
"strings"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
)
const qifBankTransactionHeader = "!Type:Bank"
const qifCashTransactionHeader = "!Type:Cash"
const qifCreditCardTransactionHeader = "!Type:CCard"
const qifAssetAccountTransactionHeader = "!Type:Oth A"
const qifLiabilityAccountTransactionHeader = "!Type:Oth L"
const qifMemorizedTransactionHeader = "!Type:Memorized"
const qifMemorisedTransactionHeader = "!Type:Memorised"
const qifInvestmentTransactionHeader = "!Type:Invst"
const qifAccountHeader = "!Account"
const qifCategoryHeader = "!Type:Cat"
const qifClassHeader = "!Type:Class"
const qifTypeHeaderPrefix = "!Type:"
const qifEntryStartRune = '!'
const qifEntryEnd = '^'
// qifDataReader defines the structure of quicken interchange format (qif) data reader
type qifDataReader struct {
allLines []string
}
// read returns the imported qif data
// Reference: https://www.w3.org/2000/10/swap/pim/qif-doc/QIF-doc.htm
func (r *qifDataReader) read(ctx core.Context) (*qifData, error) {
if len(r.allLines) < 1 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
data := &qifData{}
var currentEntryHeader string
var currentEntryData []string
var currentAccount *qifAccountData
for i := 0; i < len(r.allLines); i++ {
line := r.allLines[i]
if len(line) < 1 {
continue
}
if line[0] == qifEntryStartRune {
if len(currentEntryData) > 0 {
log.Errorf(ctx, "[qif_data_reader.read] read new entry header \"%s\" after unclosed entry", line)
return nil, errs.ErrInvalidQIFFile
}
line = strings.TrimRight(line, " ")
if line == qifBankTransactionHeader ||
line == qifCashTransactionHeader ||
line == qifCreditCardTransactionHeader ||
line == qifAssetAccountTransactionHeader ||
line == qifLiabilityAccountTransactionHeader ||
line == qifMemorizedTransactionHeader ||
line == qifMemorisedTransactionHeader ||
line == qifInvestmentTransactionHeader ||
line == qifAccountHeader ||
line == qifCategoryHeader ||
line == qifClassHeader {
currentEntryHeader = line
} else if strings.Index(line, qifTypeHeaderPrefix) == 0 {
currentEntryHeader = line
log.Warnf(ctx, "[qif_data_reader.read] read unsupported entry header line \"%s\" and skip the following entries", line)
} else {
log.Warnf(ctx, "[qif_data_reader.read] read unsupported entry header line \"%s\" and skip this line", line)
}
} else if line[0] == qifEntryEnd {
entryData := currentEntryData
currentEntryData = nil
if currentEntryHeader == qifBankTransactionHeader ||
currentEntryHeader == qifCashTransactionHeader ||
currentEntryHeader == qifCreditCardTransactionHeader ||
currentEntryHeader == qifAssetAccountTransactionHeader ||
currentEntryHeader == qifLiabilityAccountTransactionHeader {
transactionData, err := r.parseTransaction(ctx, entryData, false)
if err != nil {
return nil, err
}
if transactionData == nil {
continue
}
transactionData.account = currentAccount
if currentEntryHeader == qifBankTransactionHeader {
data.bankAccountTransactions = append(data.bankAccountTransactions, transactionData)
} else if currentEntryHeader == qifCashTransactionHeader {
data.cashAccountTransactions = append(data.cashAccountTransactions, transactionData)
} else if currentEntryHeader == qifCreditCardTransactionHeader {
data.creditCardAccountTransactions = append(data.creditCardAccountTransactions, transactionData)
} else if currentEntryHeader == qifAssetAccountTransactionHeader {
data.assetAccountTransactions = append(data.assetAccountTransactions, transactionData)
} else if currentEntryHeader == qifLiabilityAccountTransactionHeader {
data.liabilityAccountTransactions = append(data.liabilityAccountTransactions, transactionData)
}
} else if currentEntryHeader == qifMemorizedTransactionHeader || currentEntryHeader == qifMemorisedTransactionHeader {
transactionData, err := r.parseMemorizedTransaction(ctx, entryData)
if err != nil {
return nil, err
}
if transactionData == nil {
continue
}
transactionData.account = currentAccount
data.memorizedTransactions = append(data.memorizedTransactions, transactionData)
} else if currentEntryHeader == qifInvestmentTransactionHeader {
transactionData, err := r.parseInvestmentTransaction(ctx, entryData)
if err != nil {
return nil, err
}
if transactionData == nil {
continue
}
transactionData.account = currentAccount
data.investmentAccountTransactions = append(data.investmentAccountTransactions, transactionData)
} else if currentEntryHeader == qifAccountHeader {
accountData, err := r.parseAccount(ctx, entryData)
if err != nil {
return nil, err
}
if accountData == nil {
continue
}
currentAccount = accountData
data.accounts = append(data.accounts, accountData)
} else if currentEntryHeader == qifCategoryHeader {
categoryData, err := r.parseCategory(ctx, entryData)
if err != nil {
return nil, err
}
if categoryData == nil {
continue
}
data.categories = append(data.categories, categoryData)
} else if currentEntryHeader == qifClassHeader {
classData, err := r.parseClass(ctx, entryData)
if err != nil {
return nil, err
}
if classData == nil {
continue
}
data.classes = append(data.classes, classData)
} else {
log.Warnf(ctx, "[qif_data_reader.read] read unsupported entry header \"%s\" and skip this entry", currentEntryHeader)
}
} else if currentEntryHeader != "" {
currentEntryData = append(currentEntryData, line)
} else {
log.Warnf(ctx, "[qif_data_reader.read] read unsupported line \"%s\" and skip this line", line)
}
}
return data, nil
}
func (r *qifDataReader) parseTransaction(ctx core.Context, data []string, ignoreUnknown bool) (*qifTransactionData, error) {
if len(data) < 1 {
return nil, nil
}
transactionData := &qifTransactionData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'D' {
transactionData.date = line[1:]
} else if line[0] == 'T' {
transactionData.amount = line[1:]
} else if line[0] == 'C' {
transactionData.clearedStatus = r.parseClearedStatus(ctx, line[1:])
} else if line[0] == 'N' {
transactionData.num = line[1:]
} else if line[0] == 'P' {
transactionData.payee = line[1:]
} else if line[0] == 'M' {
transactionData.memo = line[1:]
} else if line[0] == 'A' {
transactionData.addresses = append(transactionData.addresses, line[1:])
} else if line[0] == 'L' {
transactionData.category = line[1:]
} else if line[0] == 'S' {
transactionData.subTransactionCategory = append(transactionData.subTransactionCategory, line[1:])
} else if line[0] == 'E' {
transactionData.subTransactionMemo = append(transactionData.subTransactionMemo, line[1:])
} else if line[0] == '$' {
transactionData.subTransactionAmount = append(transactionData.subTransactionAmount, line[1:])
} else {
if !ignoreUnknown {
log.Warnf(ctx, "[qif_data_reader.parseTransaction] read unsupported line \"%s\" and skip this line", line)
continue
}
}
}
return transactionData, nil
}
func (r *qifDataReader) parseMemorizedTransaction(ctx core.Context, data []string) (*qifMemorizedTransactionData, error) {
if len(data) < 1 {
return nil, nil
}
baseTransactionData, err := r.parseTransaction(ctx, data, true)
if err != nil {
return nil, err
}
transactionData := &qifMemorizedTransactionData{
qifTransactionData: *baseTransactionData,
amortization: qifMemorizedTransactionAmortizationData{},
}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
// these lines has been already processed in parseTransaction
if line[0] == 'D' || line[0] == 'T' || line[0] == 'C' || line[0] == 'N' ||
line[0] == 'P' || line[0] == 'M' || line[0] == 'A' || line[0] == 'L' ||
line[0] == 'S' || line[0] == 'E' || line[0] == '$' {
continue
}
if line[0] == 'K' {
if line == string(qifCheckTransactionType) {
transactionData.transactionType = qifCheckTransactionType
} else if line == string(qifDepositTransactionType) {
transactionData.transactionType = qifDepositTransactionType
} else if line == string(qifPaymentTransactionType) {
transactionData.transactionType = qifPaymentTransactionType
} else if line == string(qifInvestmentTransactionType) {
transactionData.transactionType = qifInvestmentTransactionType
} else if line == string(qifElectronicPayeeTransactionType) {
transactionData.transactionType = qifElectronicPayeeTransactionType
} else {
log.Warnf(ctx, "[qif_data_reader.parseMemorizedTransaction] read unsupported transaction type \"%s\" and skip this line", line)
continue
}
} else if line[0] == '1' {
transactionData.amortization.firstPaymentDate = line[1:]
} else if line[0] == '2' {
transactionData.amortization.totalYearsForLoan = line[1:]
} else if line[0] == '3' {
transactionData.amortization.numberOfPayments = line[1:]
} else if line[0] == '4' {
transactionData.amortization.numberOfPeriodsPerYear = line[1:]
} else if line[0] == '5' {
transactionData.amortization.interestRate = line[1:]
} else if line[0] == '6' {
transactionData.amortization.currentLoanBalance = line[1:]
} else if line[0] == '7' {
transactionData.amortization.originalLoanAmount = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseMemorizedTransaction] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return transactionData, nil
}
func (r *qifDataReader) parseInvestmentTransaction(ctx core.Context, data []string) (*qifInvestmentTransactionData, error) {
if len(data) < 1 {
return nil, nil
}
transactionData := &qifInvestmentTransactionData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'D' {
transactionData.date = line[1:]
} else if line[0] == 'N' {
transactionData.action = line[1:]
} else if line[0] == 'Y' {
transactionData.security = line[1:]
} else if line[0] == 'I' {
transactionData.price = line[1:]
} else if line[0] == 'Q' {
transactionData.quantity = line[1:]
} else if line[0] == 'T' {
transactionData.amount = line[1:]
} else if line[0] == 'C' {
transactionData.clearedStatus = r.parseClearedStatus(ctx, line[1:])
} else if line[0] == 'P' {
transactionData.text = line[1:]
} else if line[0] == 'M' {
transactionData.memo = line[1:]
} else if line[0] == 'O' {
transactionData.commission = line[1:]
} else if line[0] == 'L' {
transactionData.accountForTransfer = line[1:]
} else if line[0] == '$' {
transactionData.amountTransferred = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseInvestmentTransaction] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return transactionData, nil
}
func (r *qifDataReader) parseAccount(ctx core.Context, data []string) (*qifAccountData, error) {
if len(data) < 1 {
return nil, nil
}
accountData := &qifAccountData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'N' {
accountData.name = line[1:]
} else if line[0] == 'T' {
accountData.accountType = line[1:]
} else if line[0] == 'D' {
accountData.description = line[1:]
} else if line[0] == 'L' {
accountData.creditLimit = line[1:]
} else if line[0] == '/' {
accountData.statementBalanceDate = line[1:]
} else if line[0] == '$' {
accountData.statementBalanceAmount = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseAccount] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return accountData, nil
}
func (r *qifDataReader) parseCategory(ctx core.Context, data []string) (*qifCategoryData, error) {
if len(data) < 1 {
return nil, nil
}
categoryData := &qifCategoryData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'N' {
categoryData.name = line[1:]
} else if line[0] == 'D' {
categoryData.description = line[1:]
} else if line[0] == 'T' {
categoryData.taxRelated = true
} else if line[0] == 'I' {
categoryData.categoryType = qifIncomeTransaction
} else if line[0] == 'E' {
categoryData.categoryType = qifExpenseTransaction
} else if line[0] == 'B' {
categoryData.budgetAmount = line[1:]
} else if line[0] == 'R' {
categoryData.taxScheduleInformation = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseCategory] read unsupported line \"%s\" and skip this line", line)
continue
}
}
if categoryData.categoryType == "" {
categoryData.categoryType = qifExpenseTransaction
}
return categoryData, nil
}
func (r *qifDataReader) parseClass(ctx core.Context, data []string) (*qifClassData, error) {
if len(data) < 1 {
return nil, nil
}
classData := &qifClassData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'N' {
classData.name = line[1:]
} else if line[0] == 'D' {
classData.description = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseClass] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return classData, nil
}
func (r *qifDataReader) parseClearedStatus(ctx core.Context, value string) qifTransactionClearedStatus {
if value == "" {
return qifClearedStatusUnreconciled
} else if value == "*" || strings.ToUpper(value) == "C" {
return qifClearedStatusCleared
} else if strings.ToUpper(value) == "R" || strings.ToUpper(value) == "X" {
return qifClearedStatusReconciled
} else {
log.Warnf(ctx, "[qif_data_reader.parseClearedStatus] read unsupported transaction cleared status \"%s\" and skip this value", value)
return qifClearedStatusUnreconciled
}
}
func createNewQifDataReader(data []byte) *qifDataReader {
fallback := unicode.UTF8.NewDecoder()
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
scanner := bufio.NewScanner(reader)
allLines := make([]string, 0)
for scanner.Scan() {
allLines = append(allLines, scanner.Text())
}
return &qifDataReader{
allLines: allLines,
}
}

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