Compare commits

..

765 Commits

Author SHA1 Message Date
MaysWind 4d9643dcb2 raise an error if the file does not exist when uploading an artifact 2025-09-27 17:20:45 +08:00
MaysWind 5dc4ad60ba remove unnecessary parameter 2025-09-27 17:12:41 +08:00
MaysWind 2e5dd7d513 add content encoding to the response headers when the server returns js, csv or tsv 2025-09-27 17:00:42 +08:00
MaysWind efe088f591 update README.md 2025-09-27 16:19:27 +08:00
MaysWind d5016e853e update badges 2025-09-27 16:00:47 +08:00
MaysWind d334bd7b9a automatically create release via github action 2025-09-27 16:00:41 +08:00
MaysWind 21edf0157a modify file name 2025-09-27 15:59:45 +08:00
MaysWind 388167705a build package for windows via github actions 2025-09-27 15:59:14 +08:00
MaysWind 2423b37cbb update uploading artifact job 2025-09-27 14:46:48 +08:00
MaysWind 786796d457 update uploading artifact job 2025-09-27 08:14:36 +08:00
MaysWind 0ed9216260 create tar.gz packages via github actions 2025-09-26 23:20:09 +08:00
MaysWind eb13f10121 move enable_two_factor, enable_forget_password and forget_password_require_email_verify option to the auth section 2025-09-26 21:51:26 +08:00
MaysWind 76ce6f6f9c fix incorrect comment 2025-09-26 21:34:34 +08:00
MaysWind eb305139f5 fix the locale settings on the profile page does not immediately update the preview 2025-09-25 23:32:02 +08:00
MaysWind 8df73f202a update splash screen image file name 2025-09-24 23:11:50 +08:00
MaysWind c22751de6f add new translation contributor 2025-09-24 23:11:41 +08:00
MaysWind c3f1cb0c61 update transaction 2025-09-24 23:11:27 +08:00
MaysWind fc1fc58aa1 update locale default settings 2025-09-24 22:40:38 +08:00
Natthavat Sukdumrongpreecha e4b5e96534 fix: update var name from en to th 2025-09-24 12:25:31 +08:00
Natthavat Sukdumrongpreecha 66303a8965 feat: translate to thai language 2025-09-24 12:25:31 +08:00
MaysWind 9589dd2486 add github issue template 2025-09-23 01:39:18 +08:00
MaysWind 3d5b887e23 support Google AI LLM provider 2025-09-23 00:50:44 +08:00
MaysWind b967a214cb code refactor 2025-09-23 00:28:17 +08:00
MaysWind 5a9877588f improve date time recognition in AI image recognition 2025-09-22 22:21:35 +08:00
MaysWind fc5f8e4633 update go.mod 2025-09-22 22:04:34 +08:00
MaysWind 028bca50ea renamed structs and interfaces to reduce ambiguity 2025-09-22 22:04:06 +08:00
MaysWind 6853bbfb68 renamed structs and interfaces to reduce ambiguity 2025-09-22 22:02:37 +08:00
MaysWind d4fee27a3d fix the result of detecting whether a transaction has been modified is wrong when no default account is set 2025-09-22 00:59:10 +08:00
MaysWind 245fdd78e4 clear all transactions of specified account (#228) 2025-09-22 00:26:59 +08:00
MaysWind cbe784172e fix cannot connect PostgreSQL via Unix socket (#253) 2025-09-21 22:00:22 +08:00
MaysWind bf21e45cba update description of query_transactions mcp tool json scheme 2025-09-21 21:00:14 +08:00
MaysWind 359c430a39 add new translation contributor 2025-09-21 20:53:19 +08:00
MaysWind 669a217180 improve the style of long tips and drag-and-drop image 2025-09-21 20:48:50 +08:00
MaysWind e9507241ed update transaction 2025-09-21 20:34:17 +08:00
brieucdlf f2536749f6 feat(locale): add French translation 2025-09-21 19:05:52 +08:00
MaysWind 118558d25b modify the structure of the large language model options in the configuration file 2025-09-21 17:49:49 +08:00
MaysWind d9cd270ff4 don't save draft when creating transactions through AI image recognition in mobile version 2025-09-21 15:51:41 +08:00
MaysWind 9dee449f10 fix the incorrect mouse pointer style during image recognition 2025-09-21 15:31:53 +08:00
MaysWind ae19ca4383 improved the experience when no transaction information is detected 2025-09-21 15:28:26 +08:00
MaysWind 32fed8d6fb fix the application could not start if no LLM provider was configured 2025-09-21 15:16:34 +08:00
MaysWind ec325c9e6b fix AI image recognition limit could not be set for users 2025-09-21 15:15:50 +08:00
MaysWind 5fbb29abd3 modify style 2025-09-21 12:29:04 +08:00
MaysWind f06c6523a2 add capture=camera to the accept attribute in file input when selecting transaction pictures 2025-09-21 12:00:19 +08:00
MaysWind 02514fc457 use system control to take photo or select an image 2025-09-21 12:00:11 +08:00
MaysWind 5d88287ae2 create transactions from AI receipt image recognition results 2025-09-21 04:00:57 +08:00
MaysWind 00f1d0418f show execution error in preview area 2025-09-20 22:54:10 +08:00
MaysWind 18b270debb support for disabling the app’s built-in swipe-back navigation (#249) 2025-09-20 22:02:18 +08:00
MaysWind d947164eb6 add unit test 2025-09-20 21:34:13 +08:00
MaysWind 1a1bb6077c hidden transaction tags are not allowed when importing transactions or using the add_transaction mcp tool 2025-09-20 21:22:44 +08:00
MaysWind 05b5cab12b fix the add_transaction mcp tool set wrong categories from the wrong category type 2025-09-20 21:19:41 +08:00
MaysWind a82fdd4946 add unit tests 2025-09-20 21:08:42 +08:00
MaysWind 4def7ed60c the query_transactions_tool_handler mcp tool supports filtering multiple categories or accounts with the same name, and filtering sub-accounts / secondary categories by their parent account / category name 2025-09-20 21:08:34 +08:00
MaysWind d50ce0140f code refactor 2025-09-20 02:08:39 +08:00
MaysWind 51678aee04 don't return hidden tags for query_all_transaction_tags mcp tool 2025-09-20 00:42:42 +08:00
MaysWind 019689087d code refactor 2025-09-19 23:40:37 +08:00
MaysWind 0c1d77f7ae modify text 2025-09-19 21:35:48 +08:00
MaysWind 8de51e6e71 show the selected count even when the number of items is less than 10 2025-09-19 00:27:07 +08:00
MaysWind dc993da218 import investment transactions from alipay statement file (#246) 2025-09-19 00:18:19 +08:00
MaysWind 983f7fec0f fix can not replace empty transaction categories when using batch rules 2025-09-18 22:51:00 +08:00
MaysWind ce74c4817b fix transaction type was not checked when replacing transaction categories using batch rules (#248) 2025-09-18 22:42:30 +08:00
MaysWind bc363438f1 move the request_id_header option to the server section and the enable_two_factor option to the user section 2025-09-17 21:16:49 +08:00
MaysWind 979b16d520 display time numbers with fixed width style in the mobile version time picker 2025-09-17 21:07:22 +08:00
MaysWind 9686eb020f fix cannot import OFX file which has only one but very long line (#243) 2025-09-15 22:51:16 +08:00
MaysWind 88dea9acaa use the current browser's time zone as the sample 2025-09-15 00:38:39 +08:00
MaysWind c75fdfea1c support custom script to process delimiter-separated values (data) file / data 2025-09-15 00:21:14 +08:00
MaysWind 538d2b8205 the add transaction page of mobile version supports the destinationAccountId and comment parameters 2025-09-14 17:41:21 +08:00
MaysWind 30d36a3b07 add noUncheckedIndexedAccess compiler options 2025-09-14 17:29:42 +08:00
MaysWind 95bcd8e4c8 upgrade third party dependencies 2025-09-14 17:29:23 +08:00
MaysWind 1a8ce7d58d use for-of statements to replace for and for-in 2025-09-14 17:18:47 +08:00
MaysWind 4700446ca0 use for-of statements to replace for and for-in 2025-09-14 01:43:04 +08:00
MaysWind 67bc81d3e2 fix user custom exchange rates update page /dialog could not be opened 2025-09-14 01:17:46 +08:00
MaysWind 878a3a018e modify style 2025-09-14 01:11:02 +08:00
MaysWind e463c2dc95 batch adding transaction tags in import transaction tool 2025-09-13 23:05:26 +08:00
MaysWind 422cf49517 batch removing specified tag or batch removing invalid tag in import transaction tool 2025-09-13 23:00:39 +08:00
MaysWind 77d2426c14 code refactor 2025-09-13 21:17:55 +08:00
MaysWind 1c4dc55bb6 modify style 2025-09-13 15:37:25 +08:00
MaysWind ba72f421dc code refactor 2025-09-13 15:34:20 +08:00
MaysWind 36d1e01008 fix the display format of the fiscal year start date not updated after changing the number system on user profile page 2025-09-13 02:19:57 +08:00
MaysWind e52c7037c7 import transactions from JD.com finance statement file (#240) 2025-09-13 01:53:41 +08:00
MaysWind f5235ba08e modify text 2025-09-13 01:53:41 +08:00
xiaolvdou adc4899ea6 fix MCP protocol version requirement 2025-09-11 10:40:34 +08:00
MaysWind 34c5a1750e use for-of statements to replace for and for-in 2025-09-09 23:48:42 +08:00
MaysWind c75a902d84 fix could not select category name via checkbox in the batch create category dialog 2025-09-09 23:22:58 +08:00
MaysWind 7e2e1a4ad3 use truncation instead of rounding down or rounding to the nearest value when numerical calculations exceed precision limits 2025-09-09 20:46:51 +08:00
MaysWind d4603a1892 code refactor 2025-09-09 00:01:15 +08:00
MaysWind 642e51bc0c use and display the Gregorian calendar when calculating months, quarters, years, and fiscal years 2025-09-08 00:32:30 +08:00
MaysWind 5591abdb3b upgrade third party dependencies 2025-09-07 23:14:59 +08:00
MaysWind ce9378c43f support filtering accounts and transaction categories for overview in home page (#209) 2025-09-07 13:57:07 +08:00
MaysWind 3ae72623ad code refactor 2025-09-07 00:15:35 +08:00
MaysWind affc02655b remove redundant spaces 2025-09-06 23:34:35 +08:00
MaysWind a469d66358 calendar display type supports Gregorian with Persian, date display type supports Persian calendar 2025-09-06 23:34:29 +08:00
MaysWind 757f9e5b02 fix the toast text size and the text input size in dialogs on the mobile version did not adjust according to the font size settings 2025-09-06 01:18:05 +08:00
MaysWind 8368b02be8 calendar display type supports Gregorian with Chinese 2025-09-06 01:06:31 +08:00
MaysWind e15a5617e6 support dates with YYYYMMDD format when importing delimiter-separated values file / data (#219) 2025-09-04 22:04:14 +08:00
MaysWind f604b2c766 upgrade golang to 1.25.1, node.js to 24.7.0 2025-09-04 21:57:19 +08:00
MaysWind d6dc9f8170 fix the checkbox in the scheduled transaction frequency dropdown list couldn’t be used to select options (#225) 2025-09-03 19:50:00 +08:00
MaysWind a71be1bf05 fix cannot delete accounts / transaction categories and tags when using postgres db (#218) 2025-09-01 22:44:34 +08:00
MaysWind bcf11631d6 use integers to calculate formulas for beancount amount formula 2025-09-01 01:16:58 +08:00
MaysWind 989183c8be use integers to calculate formulas for evaluator (#214) 2025-09-01 00:32:14 +08:00
MaysWind 8bd0fd88af bump version to 1.1.0 2025-08-31 13:25:47 +08:00
MaysWind 20e2444307 modify color and background image in desktop version 2025-08-31 00:20:17 +08:00
MaysWind 8154bd712b check if the accounts, categories, and tags used exist when creating a transaction template 2025-08-30 01:22:40 +08:00
MaysWind 4d0e376568 code refactor 2025-08-30 00:40:33 +08:00
MaysWind 32cf41a7a0 modify style 2025-08-30 00:07:43 +08:00
MaysWind e85a4701ed fix cannot selecting time when the number system was not Arabic numerals 2025-08-29 00:53:23 +08:00
MaysWind b79ffafaee code refactor 2025-08-29 00:43:34 +08:00
MaysWind 8f6adaa417 fix some numerals were not displayed according to the numerical system 2025-08-29 00:43:11 +08:00
MaysWind 0e634d83f4 update README.md 2025-08-28 23:59:18 +08:00
MaysWind af8cbe0b15 fix the ellipsis was not displayed when the text was too long 2025-08-28 22:43:25 +08:00
MaysWind 411130db4e support calendar display type (Gregorian and Buddhist) 2025-08-28 00:31:59 +08:00
MaysWind c099443783 support date display type (Gregorian and Buddhist) 2025-08-27 00:58:22 +08:00
MaysWind 23ffdbb163 replacing third-party datetime formatter with internal formatter 2025-08-25 23:33:49 +08:00
MaysWind 0b48502a10 modify the style of the custom time range 2025-08-25 22:21:57 +08:00
MaysWind 25681f622d add explicit type for string-based datetimes, replacing third-party datetime type with internal DateTime type 2025-08-25 00:39:36 +08:00
MaysWind f196ce969b code refactor 2025-08-24 23:07:18 +08:00
MaysWind 0408c470fc fix the select dropdown menu was positioned incorrectly after filter box being focused 2025-08-24 23:06:58 +08:00
MaysWind 01aeb945ff check whether transaction template uses specified accounts / categories / tags when deleting them 2025-08-24 01:29:26 +08:00
MaysWind 601a1f83c6 fix the date range may be incorrect when switching between fiscal years 2025-08-24 00:23:49 +08:00
MaysWind 2a470742e0 change order 2025-08-24 00:08:34 +08:00
MaysWind 8ba1e1997f show the time range below the fiscal year option 2025-08-24 00:04:36 +08:00
MaysWind 27ae401a7f code refactor 2025-08-23 23:21:02 +08:00
MaysWind 81727d3b1e show import file type by categories 2025-08-23 02:25:24 +08:00
MaysWind 06a0501633 append error message to log 2025-08-22 21:48:35 +08:00
MaysWind 781c2d9044 retry up to 3 times when update user cloud settings 2025-08-22 00:08:08 +08:00
MaysWind 15e4ad00ee update description 2025-08-21 22:20:56 +08:00
MaysWind 8064a00252 fix confirm dialog color not taking effect 2025-08-21 00:03:25 +08:00
MaysWind f2d0fe407b support deleting all transactions (#202) 2025-08-21 00:01:25 +08:00
MaysWind 9589657fd5 update description 2025-08-20 22:55:16 +08:00
MaysWind 790837076f code refactor 2025-08-20 22:39:49 +08:00
MaysWind 6d923027a0 update README.md 2025-08-20 22:19:11 +08:00
MaysWind 13d5759e84 mobile version supports rtl 2025-08-20 01:13:39 +08:00
MaysWind efe39c7390 fix circular dependency problem 2025-08-18 01:30:58 +08:00
MaysWind c00770201b desktop version supports rtl 2025-08-18 00:45:26 +08:00
MaysWind 4eff3a337f fix repeated execution of multilingual processing 2025-08-17 02:09:09 +08:00
MaysWind 451385011e fix the default date was incorrect when updating the closing balance in the reconciliation statement dialog which the date range is all 2025-08-17 02:04:28 +08:00
MaysWind cd4d230d29 support changing numeral system 2025-08-17 01:55:19 +08:00
MaysWind ab6d4ee6fc code refactor 2025-08-16 01:07:27 +08:00
MaysWind 274aa6a17c digit grouping type supports Indian Number Grouping 2025-08-16 00:20:12 +08:00
MaysWind 2f8d4ad5e4 add new translation contributor 2025-08-13 21:57:04 +08:00
MaysWind fe59d3b280 update transaction 2025-08-13 09:52:32 +08:00
automagics e2c99c4f04 Add Dutch translation 2025-08-13 09:24:27 +08:00
MaysWind 127393b64a display the "Outstanding Balance" label instead of "Balance" for liability account category in desktop account list page 2025-08-10 17:10:59 +08:00
MaysWind f3d240442b modify balance modification transaction 2025-08-10 17:00:08 +08:00
MaysWind 55bf8b9e30 support showing transaction detail dialog / page 2025-08-10 16:59:08 +08:00
MaysWind eadcf7768f fix the total amount of all sub-accounts under the parent account was not counted after filtering any sub-accounts (#192) 2025-08-10 11:26:27 +08:00
MaysWind 876bf8cc31 update dark theme style of desktop version 2025-08-10 01:38:08 +08:00
MaysWind 6b5aac0111 fix the filter box could not be input 2025-08-09 02:13:06 +08:00
MaysWind dc4a4e1463 import latest wechat pay billing file format 2025-08-08 20:37:50 +08:00
MaysWind 0677ed07db code refactor 2025-08-08 20:19:02 +08:00
MaysWind ecf6fbd187 support setting whether the data table in csv / xls / xlsx files contains a header row 2025-08-08 20:15:11 +08:00
MaysWind 351cebe169 add sub basic data table 2025-08-08 20:05:39 +08:00
MaysWind 0f94a90882 add unit tests 2025-08-08 20:05:26 +08:00
MaysWind 04996d784f show the reason why the category / account select is disabled in desktop version 2025-08-06 00:05:35 +08:00
MaysWind aafcfeda84 upgrade node.js to 22.18.0 2025-08-05 23:37:22 +08:00
MaysWind 7283b724b1 add candlestick chart for account balance trends 2025-08-05 23:29:49 +08:00
MaysWind 0d55912f6c modify UI interaction for mode switching 2025-08-05 01:01:52 +08:00
MaysWind 60108e26c7 fix incorrect fiscal year closing balance for partial-year queries 2025-08-05 01:01:33 +08:00
MaysWind be129cd3c6 modify function name 2025-08-04 23:33:13 +08:00
MaysWind f210bfa9f4 use the first visible account as default if the default account is hidden when creating new transaction 2025-08-04 23:14:46 +08:00
MaysWind 263113a67f upgrade third party dependencies 2025-08-04 22:44:41 +08:00
MaysWind 3b29303237 show the reason why the transaction, account, and category cannot be saved on the save button in desktop version 2025-08-04 21:43:19 +08:00
MaysWind 6e5f857e97 show account outstanding balance for liability account in account balance trends chart 2025-08-04 21:04:41 +08:00
MaysWind 791c0ea26e modify style 2025-08-04 21:03:37 +08:00
MaysWind 84523d8b8a code refactor 2025-08-04 20:54:10 +08:00
MaysWind d35e127b9e code refactor 2025-08-04 01:25:55 +08:00
MaysWind ebe00d3271 code refactor 2025-08-04 01:22:54 +08:00
MaysWind 14b4e40039 reconciliation statement page / dialog supports account balance trends chart (#184) 2025-08-04 01:22:36 +08:00
MaysWind 15d1d269ae modify file name 2025-08-03 21:42:30 +08:00
MaysWind e90b76c80e code refactor 2025-08-03 14:27:34 +08:00
MaysWind e28e27080a fix the bug the default account shows "unspecified" in desktop version after the default account is set to hidden 2025-08-03 01:16:51 +08:00
MaysWind 2268496dcb translate text in vuetify controls 2025-08-03 00:58:49 +08:00
MaysWind 3781327c58 hide hidden sub-account 2025-08-02 23:53:11 +08:00
MaysWind 51c33d7e83 upgrade golang to 1.24.5, node.js to 22.17.0, alpine base image to 3.22.1 2025-08-02 10:45:00 +08:00
MaysWind 975a56e7d9 don't initialize avatar / transaction storage when they are not enabled 2025-08-02 10:16:00 +08:00
MaysWind 29a87dcfaf object storage supports webdav 2025-08-02 01:26:29 +08:00
MaysWind cad53d0bfc use the request context 2025-08-02 00:10:12 +08:00
MaysWind 56a3905df1 exclude deleted account 2025-08-01 23:21:37 +08:00
MaysWind 428a1f2156 show "data is up to date" when the data is not updated in reconciliation statement page / dialog 2025-08-01 23:13:03 +08:00
MaysWind b5233399e6 code refactor 2025-08-01 23:03:59 +08:00
MaysWind f8878c5405 disable transaction draft when update closing balance 2025-07-31 23:41:52 +08:00
MaysWind 8dcaa457f9 set the default date based on the current date time filter range when adding a new transaction in the transaction list page 2025-07-31 22:53:37 +08:00
MaysWind b24ebdb83e redesign time picker in date time select for desktop device 2025-07-31 22:19:12 +08:00
MaysWind d41a2141a7 modify style 2025-07-31 14:35:52 +08:00
MaysWind 09a1dd0358 update closing balance in reconciliation statement page 2025-07-30 23:07:04 +08:00
MaysWind 531c4a44d5 code refactor 2025-07-30 22:53:43 +08:00
MaysWind ceecff8c24 add refresh button in reconciliation statement dialog 2025-07-30 22:27:58 +08:00
MaysWind f32cc4ab04 fix the filter could not be saved after the filtered accounts, categories and transaction tags were deleted (#185) 2025-07-30 21:12:15 +08:00
MaysWind 8fa46281e0 update closing balance in reconciliation statement page / dialog 2025-07-30 00:56:55 +08:00
MaysWind f7bc4b3ab6 modify style 2025-07-30 00:56:34 +08:00
MaysWind ad4f5bd88d support duplicating / modifying / deleting transaction in reconciliation statement page 2025-07-29 00:58:15 +08:00
MaysWind e4cb66718d reload reconciliation statement after adding new transaction 2025-07-29 00:16:37 +08:00
MaysWind 175b272fa0 disable the view button of the balance modification transaction 2025-07-29 00:03:45 +08:00
MaysWind ca0fb9446b total inflows and outflows in reconciliation statement includes the amount of the balance modification transaction 2025-07-29 00:01:38 +08:00
MaysWind 6eb749dca2 move file 2025-07-28 23:20:45 +08:00
MaysWind 880b614636 clear the data of the last unfinished calculation when opening the number pad sheet 2025-07-28 23:17:39 +08:00
MaysWind d146a99c65 modify style 2025-07-28 22:49:15 +08:00
MaysWind fd99c784b3 modify style 2025-07-28 22:00:26 +08:00
MaysWind 22f9c5243a add reconciliation statement page for mobile version 2025-07-28 00:37:51 +08:00
MaysWind 67f5aaa5ee update README.md 2025-07-27 23:51:31 +08:00
MaysWind 713b621169 update category display name for balance modification transaction 2025-07-27 21:35:16 +08:00
MaysWind 80df5f95aa fix typo 2025-07-27 17:07:04 +08:00
MaysWind 1e492d8724 code refactor 2025-07-27 16:59:31 +08:00
MaysWind 602f15fe2e export reconciliation statements 2025-07-26 00:58:38 +08:00
MaysWind 3335533a18 remove token via cli 2025-07-25 00:01:05 +08:00
MaysWind d385358aa3 code refactor 2025-07-24 23:58:24 +08:00
MaysWind d6ee8a416f code refactor 2025-07-24 23:57:49 +08:00
MaysWind c5aa37037f the number of digits shown for hours, minutes, and seconds in the time picker depends on the user's language settings 2025-07-23 01:06:34 +08:00
MaysWind 6050f5deab add / view transaction in reconciliation statement dialog 2025-07-23 00:47:22 +08:00
MaysWind 5d07d1a70d modify style 2025-07-23 00:09:15 +08:00
MaysWind bae330c6f3 modify text 2025-07-23 00:02:34 +08:00
MaysWind ea17994c6c show opening / closing balance in reconciliation statement dialog 2025-07-22 01:04:29 +08:00
MaysWind c3d29ee2f8 modify text 2025-07-21 00:53:39 +08:00
MaysWind 515b9af61a add reconciliation statement in desktop version 2025-07-21 00:40:02 +08:00
MaysWind 4ba3893b83 remove unused code 2025-07-21 00:39:54 +08:00
MaysWind bcb6c4f419 time selector in mobile version supports loop selection 2025-07-21 00:39:32 +08:00
MaysWind 53f101fb60 fix the bug that amount format could not be automatically detected when importing dsv file 2025-07-20 13:09:26 +08:00
MaysWind 8da4f65048 load / save rules for batch replacing transaction categories / accounts / tags when import transactions 2025-07-20 01:51:20 +08:00
MaysWind 428bcba56e code refactor 2025-07-19 23:27:32 +08:00
MaysWind 68e896d8eb use the original transaction type of Firefly III as the imported transaction type 2025-07-15 23:13:49 +08:00
MaysWind eef62722a4 update third party dependency copyright 2025-07-13 23:59:13 +08:00
MaysWind e3dcb2ce0c fix wrong link 2025-07-13 23:17:24 +08:00
MaysWind 0cf89562cd use a unified commit hash length when building in different platform 2025-07-13 21:51:14 +08:00
MaysWind 8b06731cdb update go.mod 2025-07-13 17:19:34 +08:00
MaysWind 36abd1acec bump version to 1.0.0 2025-07-13 17:19:27 +08:00
MaysWind 06ef2220d6 fix the incorrect transaction type and amount when importing some Firefly III data 2025-07-13 16:00:53 +08:00
MaysWind 29d14bb5ef update latest supported currencies of Bank of Russia / International Monetary Fund exchange data source 2025-07-13 02:04:27 +08:00
MaysWind cd2b99a44c use the export data format since Firefly III version 6.2.0 as the format for importing Firefly III data 2025-07-13 01:53:17 +08:00
MaysWind 0413f8c0aa use the expense and revenue account names as category names if the transaction has not category when importing Firefly III transactions 2025-07-13 01:51:27 +08:00
MaysWind ca5c451d36 code refactor 2025-07-13 01:43:11 +08:00
MaysWind c19b87275d modify log content 2025-07-13 01:43:04 +08:00
MaysWind 7a374a509a modify file name 2025-07-12 16:08:35 +08:00
MaysWind 01aa2cf0a4 filter transaction description keywords in statistics & analysis page 2025-07-08 00:31:50 +08:00
MaysWind 5c9eb5dc5a modify style 2025-07-07 23:30:11 +08:00
MaysWind b05a53ffe3 reduce the number of skeleton rows when loading in transaction list page 2025-07-07 22:58:41 +08:00
MaysWind 0387551c43 change mcp token icon 2025-07-07 22:47:16 +08:00
MaysWind 773f808a35 update token last seen time when call mcp initialize api 2025-07-07 22:38:50 +08:00
MaysWind 07477eb5f8 hide generate mcp token when mcp is not enabled 2025-07-07 22:28:00 +08:00
MaysWind 5cb129311a feature restriction supports mcp 2025-07-07 01:21:09 +08:00
MaysWind 6215f489f2 code refactor 2025-07-07 01:20:55 +08:00
MaysWind 0140fc7622 add a special token type for MCP 2025-07-07 01:20:38 +08:00
MaysWind fbaf6086e3 update the Excelize version to the one actually used 2025-07-06 22:07:19 +08:00
MaysWind 5a1b649011 add transaction mcp handler 2025-07-06 22:03:26 +08:00
MaysWind 6da42686a9 add query accounts / transaction categories / transaction tags mcp handler 2025-07-06 20:02:42 +08:00
MaysWind 82b98eca95 code refactor 2025-07-06 20:02:09 +08:00
MaysWind a54275d307 fix missing text in description 2025-07-06 15:50:24 +08:00
MaysWind e1e61e8570 update git ignore file 2025-07-06 14:39:38 +08:00
MaysWind ebc7e7256a update description 2025-07-06 03:23:13 +08:00
MaysWind 93887ec2bb update README.md 2025-07-06 03:17:38 +08:00
MaysWind 8dce0f2d6a add mcp (Model Context Protocol) support 2025-07-06 03:02:19 +08:00
MaysWind 620ccf317f update README.md 2025-07-04 23:43:19 +08:00
MaysWind 7983f17e7f update documents 2025-07-03 00:08:18 +08:00
MaysWind b60c0b29f8 update README.md 2025-07-02 23:45:28 +08:00
MaysWind 5400a1424c do not check third party response when run tests in ci pipeline 2025-07-02 22:17:25 +08:00
MaysWind 3296d21f6a fix the bug that the date was not displayed correctly during daylight saving time (#163) 2025-07-02 01:40:20 +08:00
MaysWind 2e1a9362fc export transaction data based on the conditions on the transaction list page (#55) 2025-07-01 00:01:29 +08:00
MaysWind 53aa4ff390 code refactor 2025-06-30 22:58:17 +08:00
MaysWind 3c100b2543 code refactor 2025-06-30 22:42:18 +08:00
MaysWind b37cde5a8c user feature restriction supports application settings syncing 2025-06-30 22:10:54 +08:00
MaysWind e13efdc11f add sub category name in title 2025-06-30 21:53:36 +08:00
MaysWind 303f599f7d only show version dialog when frontend and backend version are not the same 2025-06-30 21:49:24 +08:00
MaysWind a68c45a923 fix typo 2025-06-30 21:42:59 +08:00
MaysWind 96b7c69283 add refresh browser cache when client version not match server version 2025-06-30 00:39:28 +08:00
MaysWind 801c0f8572 total amount on the account list page supports excluding specified accounts (#161) 2025-06-29 22:27:34 +08:00
MaysWind 90e862fbb1 sync application settings 2025-06-29 20:25:21 +08:00
MaysWind 1eb997d2c0 code refactor 2025-06-28 20:23:44 +08:00
MaysWind 1d314b1b09 fix the bug that statistical analysis still shows the account balance in the desktop version when the account balance is set to hide 2025-06-28 18:10:35 +08:00
MaysWind a077cccc2e upgrade golang to 1.24.4, node.js to 22.16.0, alpine base image to 3.22.0 2025-06-25 23:20:13 +08:00
MaysWind 6fb7e63e88 update description 2025-06-25 23:19:34 +08:00
MaysWind 3621245212 save / load column mapping file for delimiter-separated values (dsv) file 2025-06-22 22:49:06 +08:00
MaysWind dfa573b49b code refactor 2025-06-22 22:27:45 +08:00
MaysWind a69db9d299 use the macro language tag to match the i18n file when the browser language tag cannot match any i18n files 2025-06-22 18:53:25 +08:00
MaysWind 481618037d change the text of the unset start and end time in scheduled transaction 2025-06-22 18:07:11 +08:00
MaysWind 57ead2937b add Portuguese (Brazil) localized display name in different languages 2025-06-22 18:07:00 +08:00
MaysWind e6d8cbcdd6 fix wrong localized item key and default setting in Portuguese (Brazil) 2025-06-22 18:06:31 +08:00
MaysWind a7554d884f modify language order 2025-06-22 17:54:19 +08:00
MaysWind 468a4b1bac update default localized setting 2025-06-22 17:16:48 +08:00
Gustavo Michels de Camargo c1e4cd4bf1 Adding Brazilian Portuguese translation to the frontend 2025-06-22 15:21:38 +08:00
Gustavo Michels de Camargo 4413f2c411 Adding Brazilian Portuguese translation to the backend 2025-06-22 15:21:38 +08:00
MaysWind b1349f57cd parse information to account owner data in mt940 file 2025-06-21 00:52:08 +08:00
MaysWind 4a6f7eb43c import transactions from mt940 file 2025-06-20 00:57:07 +08:00
MaysWind 8f0e6ba95a support two-digit years in the transaction date when importing QIF file 2025-06-20 00:56:57 +08:00
MaysWind e9c175d2af code refactor 2025-06-20 00:55:59 +08:00
MaysWind 5dc0e925c1 fill the first two digits for year based on the current year when importing a two-digit year 2025-06-19 22:39:27 +08:00
MaysWind 787eaad352 add comments 2025-06-18 23:39:24 +08:00
MaysWind 4bab8db7c0 code refactor 2025-06-18 23:27:37 +08:00
MaysWind b6e96586a5 update README.md 2025-06-18 00:59:08 +08:00
MaysWind 7127c5539a import transactions from camt.053 file 2025-06-18 00:53:37 +08:00
MaysWind fe7736a7f6 export statistics data to markdown file 2025-06-15 23:33:57 +08:00
MaysWind 29dcaaae47 code refactor 2025-06-15 23:11:31 +08:00
MaysWind 9090c5c223 set geo location data order when import transaction 2025-06-15 22:59:21 +08:00
MaysWind 4336d1ed1a export statistics & analysis data in desktop version 2025-06-15 21:50:31 +08:00
MaysWind 8edc3640f5 code refactor 2025-06-15 21:48:56 +08:00
MaysWind 39e81af782 code refactor 2025-06-15 20:42:15 +08:00
MaysWind cc16f57a44 use vue-tsc instead of tsc to check code 2025-06-11 00:20:26 +08:00
MaysWind e7e7caae3b check typescript code in vue sfc file 2025-06-10 08:39:29 +08:00
Sebastian Reategui e09c62cf8d add missing fiscal year start parameter for src/lib/datetime.ts:getFullMonthDateRange() 2025-06-10 08:38:51 +08:00
MaysWind 8d5fe8f0f1 modify style 2025-06-10 00:10:57 +08:00
MaysWind f9d8293fd2 code refactor 2025-06-09 23:53:36 +08:00
MaysWind 4111eb0838 code refactor 2025-06-09 23:50:30 +08:00
MaysWind cd37e2ab1d fix the data of last quarter not displayed when there is only one month in the last quarter in trend analysis 2025-06-09 00:45:17 +08:00
MaysWind 2c730b3e25 code refactor 2025-06-09 00:33:16 +08:00
MaysWind ee47ee91c3 fix incorrect data aggregated by fiscal year in trend analysis 2025-06-08 23:10:02 +08:00
MaysWind 5a47c74f83 code refactor 2025-06-08 23:09:47 +08:00
MaysWind 0c4b8f006a import robustness 2025-06-08 22:10:04 +08:00
MaysWind 0023454d9a set default fiscal year start date when user registers 2025-06-08 22:03:25 +08:00
MaysWind 45e6c56934 modify style 2025-06-08 21:44:09 +08:00
MaysWind 51eb8fa377 code refactor 2025-06-08 21:44:02 +08:00
MaysWind f905dcb3fd improve compatibility 2025-06-08 02:47:30 +08:00
MaysWind 583676314a add multilingual entries 2025-06-08 02:47:24 +08:00
MaysWind 8616183660 do unit test when building frontend files 2025-06-08 02:47:10 +08:00
MaysWind ce4bca8272 code refactor 2025-06-08 02:47:00 +08:00
MaysWind 8c71f03f6f upgrade third party dependencies 2025-06-08 00:35:33 +08:00
MaysWind c5c4ddecbe remove redundant code 2025-06-07 23:00:53 +08:00
MaysWind ceecd9d524 code refactor 2025-06-07 23:00:35 +08:00
MaysWind a5a526e554 remove unused code 2025-06-07 22:14:26 +08:00
MaysWind 88864fd4f0 code refactor 2025-06-07 22:13:51 +08:00
MaysWind 10f2b39203 code refactor 2025-06-07 22:13:34 +08:00
MaysWind 6e1899c6ad format code 2025-06-07 22:13:16 +08:00
Sebastian Reategui b94dc8eb83 Feature - Add support for a fiscal year period defined in user settings.
* Add "This fiscal year", "Last fiscal year" as date range options in Transaction Details to filter transactions to those periods
* Add fiscal year ranges to Statistics & Trend Analysis
* Add "fiscal year start date" to user profile settings, allowing the user to select any date of the calendar year as the start of the fiscal year
* Add "fiscal year format" to user profile settings, allowing the user to specify how financial year date labels should appear

Implementation notes:
* The default fiscal year start is January 1 and the default fiscal year display format is "FY 2025"
* Fiscal year start date (month number & day number) are stored together in db as a uint16, high byte & low byte respectively
* February 29 is disallowed as a fiscal year start date, since it is never used as a convention in any country
* Jest is added to the project as a dev dependency, for unit tests in frontend

Signed-off-by: Sebastian Reategui <seb.reategui@gmail.com>
2025-06-07 22:04:47 +08:00
MaysWind 70eea8ff33 show total balance of parent account in mobile version (#149) 2025-06-07 14:11:55 +08:00
MaysWind 881a9c122a downgrade excelize to 2.9.0 (because https://github.com/qax-os/excelize/issues/2132) 2025-06-07 13:11:30 +08:00
MaysWind 9e9cac0c2e upgrade third party dependencies 2025-06-02 18:51:39 +08:00
MaysWind 83b2a3645d upgrade third party dependencies 2025-06-02 16:58:08 +08:00
MaysWind ecfca1c742 bump version to 0.10.0 2025-06-02 16:45:12 +08:00
MaysWind 6222b6edae use custom number input box to replace the system input box 2025-06-02 02:05:18 +08:00
MaysWind baa6850fcb code refactor 2025-06-02 01:28:40 +08:00
MaysWind cab13cee3c code refactor 2025-06-01 23:42:35 +08:00
MaysWind b4bff49104 try to keep the selected day when navigating the transaction calendar by month 2025-06-01 01:10:27 +08:00
MaysWind cde26b76b1 don't show exchange rates data provider when use user custom exchange rates data 2025-05-28 22:22:03 +08:00
MaysWind a20ef34280 fix the wrong symbol of Eritrean nakfa 2025-05-28 22:17:58 +08:00
MaysWind 5606d40451 update README.md 2025-05-28 22:15:59 +08:00
MaysWind b3a666f876 support always showing transaction pictures in transaction edit page for mobile version 2025-05-28 00:18:55 +08:00
MaysWind 626d3895aa allow users to set coordinate display type (#141) 2025-05-27 01:01:55 +08:00
MaysWind e338c7190d add missing translation text item 2025-05-26 23:23:36 +08:00
MaysWind adfd12ef52 fix the longitude exceeds 180 degrees when selecting a new geographic location by user 2025-05-26 01:01:07 +08:00
MaysWind 817291c9a7 support user custom exchange rates data 2025-05-26 00:47:19 +08:00
MaysWind c4d20c539f code refactor 2025-05-24 23:27:09 +08:00
MaysWind d089eee133 ignore inline comment in configuration file (#140) 2025-05-24 22:27:49 +08:00
MaysWind 387df07659 fix transaction list not display after expand the month transaction list 2025-05-24 22:25:23 +08:00
MaysWind 5767acb29b improve performance for mobile transaction list page 2025-05-22 03:38:30 +08:00
MaysWind 607c1ddc48 show amount in default currency in transaction edit page / dialog when account currency is not default currency 2025-05-11 23:50:18 +08:00
MaysWind a6d45f5009 show error message when cannot load transaction picture in desktop version 2025-05-11 21:11:22 +08:00
MaysWind 6e9f427182 modify text 2025-05-11 20:36:47 +08:00
MaysWind 62ad1749e8 code refactor 2025-05-11 15:16:49 +08:00
MaysWind 9c695ee46d code refactor 2025-05-10 01:08:57 +08:00
MaysWind 1d2002e92f only months can be selected in transaction calendar mode 2025-05-10 01:05:11 +08:00
MaysWind c7b809415c modify style 2025-05-10 00:08:38 +08:00
MaysWind 55fa9ca686 the size of icon in the title bar follows the font size setting 2025-05-09 23:54:49 +08:00
MaysWind 09a6ea46b2 modify font size 2025-05-09 23:49:19 +08:00
MaysWind 56ba4d88f4 add transaction calendar for mobile version 2025-05-09 01:14:36 +08:00
MaysWind fbc8c5e8c7 modify style 2025-05-08 22:44:24 +08:00
MaysWind 8a65282820 set the calendar readonly when loading 2025-05-08 21:10:10 +08:00
MaysWind 0d8c5f3dbe code refactor 2025-05-07 00:37:37 +08:00
MaysWind dbbbe6805d fix cannot shift month when selected month is current month and in transaction calendar mode 2025-05-07 00:34:11 +08:00
MaysWind 4656002106 modify style 2025-05-07 00:22:32 +08:00
MaysWind d3758ec02f no allow to change month via swiping 2025-05-07 00:11:30 +08:00
MaysWind 81812bb31d add transaction calendar 2025-05-06 00:33:45 +08:00
MaysWind ab6f9839ef amount input supports formula (#130) 2025-05-04 22:56:15 +08:00
MaysWind d036f66d4c fix some number value not display localized decimal symbol 2025-05-04 21:30:04 +08:00
MaysWind dc24186ccb add document link for importing dsv file/data 2025-05-04 15:38:30 +08:00
MaysWind f6fbcd8608 code refactor 2025-05-02 00:34:48 +08:00
MaysWind 381d063295 support clicking on map to set specified geographic location 2025-05-02 00:32:22 +08:00
MaysWind 65a0e48988 fix repeated request error when submitting import transaction again after the first submission failed 2025-05-01 22:09:13 +08:00
MaysWind b1a928b990 update unit test 2025-05-01 13:54:10 +08:00
MaysWind b7973772b3 show process when importing a lot of transactions 2025-05-01 13:49:17 +08:00
MaysWind 20b65fd885 support event stream 2025-04-30 22:30:01 +08:00
MaysWind 850fbffdde improve performance 2025-04-30 00:07:27 +08:00
MaysWind 0af5b194fc add logs 2025-04-30 00:00:32 +08:00
MaysWind c421038808 modify log 2025-04-29 23:17:45 +08:00
MaysWind 68c078038a not map to parent accounts, hidden accounts, and hidden categories when importing transactions 2025-04-28 00:06:59 +08:00
MaysWind be5b1a52ea upgrade third party dependencies 2025-04-27 23:44:40 +08:00
MaysWind 86c5b882c2 upgrade third party dependencies 2025-04-27 23:22:26 +08:00
MaysWind b7d2653fb5 upgrade golang to 1.24.2, nodejs to 22.15.0 2025-04-27 22:49:15 +08:00
MaysWind 34a752b8d8 show edit button for sub account 2025-04-27 00:08:01 +08:00
MaysWind de217a1bbf update timezone display name 2025-04-27 00:01:14 +08:00
MaysWind 3087296263 update alternative language tag 2025-04-26 23:42:36 +08:00
MaysWind 78ba43480b support adding / deleting sub account after account created (#77) 2025-04-26 23:36:23 +08:00
MaysWind e7e2cc8081 fix cannot add preset transaction categories 2025-04-26 22:56:09 +08:00
MaysWind 0f6b61ce6c improve performance 2025-04-26 22:55:51 +08:00
MaysWind 9182e8f2ef code refactor 2025-04-26 22:12:51 +08:00
MaysWind c7870a79e5 not set duplicate submission remark when not enabled 2025-04-26 22:05:18 +08:00
MaysWind 312172fcd8 import outstanding balance modification transaction of feidee mymoney export data (#126) 2025-04-26 00:53:47 +08:00
MaysWind 4a83ba84d3 update default localized setting 2025-04-26 00:05:18 +08:00
Aron 76a9a20d89 add italian translations 2025-04-24 10:06:42 +08:00
MaysWind 567902a407 add language tag alias 2025-04-20 23:44:02 +08:00
MaysWind fca8211c4f fix the incorrect title of categories 2025-04-20 17:45:33 +08:00
MaysWind d37b023e11 modify text 2025-04-20 16:02:42 +08:00
MaysWind f175644843 add Chinese (Traditional) language 2025-04-20 15:55:23 +08:00
MaysWind 13c4ad10c5 update latest time zones 2025-04-20 14:39:49 +08:00
MaysWind 550cd848b0 update exchange rate data reference url for National Bank of Ukraine 2025-04-20 11:05:05 +08:00
MaysWind 25e0c43c0b sort the source code by country name 2025-04-20 10:33:22 +08:00
MaysWind fd9d23995d Merge branch 'main' of https://github.com/mayswind/EasyBookkeeping 2025-04-20 10:29:26 +08:00
Mykyta Lytvynenko 3a467d758e add National Bank of Ukraine exchange rates data source 2025-04-20 10:29:13 +08:00
MaysWind 60b6adfa1e update exchange rate data reference url for National Bank of Romania 2025-04-20 10:22:05 +08:00
Mykyta Lytvynenko a44ac333ab add ukrainian translation 2025-04-19 01:53:54 +08:00
Mykyta Lytvynenko deea0deb3f add ukrainian translation 2025-04-19 01:53:54 +08:00
Mykyta Lytvynenko 97178227ef add ukrainian translation 2025-04-19 01:53:54 +08:00
MaysWind fd1242490f code refactor 2025-04-19 00:04:04 +08:00
MaysWind 1ac633bdd7 use the sub-category according to the primary category name if there are duplicated sub-category names when importing transactions (#119) 2025-04-18 23:33:07 +08:00
MaysWind 44d4349f12 show currency in transaction edit dialog 2025-04-13 23:45:39 +08:00
MaysWind df31be61e8 support hiding/unhiding/deleting sub-account in account list page 2025-04-13 23:36:37 +08:00
MaysWind 68b08c1e8a use primary color & icon as default when creating secondary category 2025-04-06 22:38:49 +08:00
MaysWind f97cca6dcc update the latest supported currencies in exchange rates data from National Bank of Poland 2025-04-04 00:10:14 +08:00
MaysWind 2c9bb12da9 code refactor 2025-04-03 23:50:14 +08:00
MaysWind b059055a93 code refactor 2025-04-03 23:44:06 +08:00
MaysWind 1092cc2fdc update README.md 2025-04-03 22:10:23 +08:00
MaysWind d5e75b2a37 update README.md 2025-03-31 00:09:25 +08:00
MaysWind 0a5f8862ad allow pressing ESC or clicking outside to close add dialog when nothing is modified 2025-03-31 00:00:16 +08:00
MaysWind 433a225b9d modify error message 2025-03-30 00:38:33 +08:00
MaysWind 6dfff84ab7 not allow to close dialog by clicking outside when user has modified something in dialog 2025-03-29 21:14:45 +08:00
MaysWind 91b6047f2e batch create nonexistent transaction tags when import transaction 2025-03-29 21:02:56 +08:00
MaysWind 94ef7f450b update README.md 2025-03-29 16:04:29 +08:00
MaysWind e5cf92f84e batch create nonexistent transaction categories when import transaction 2025-03-24 00:10:50 +08:00
MaysWind 399b5c03a2 code refactor 2025-03-24 00:10:26 +08:00
MaysWind f9b7be2f74 add refresh button in batch replace dialog 2025-03-24 00:09:40 +08:00
MaysWind 66f7cc6f88 set amount format in import dialog 2025-03-24 00:09:12 +08:00
MaysWind af03597e86 move file 2025-03-23 15:09:22 +08:00
MaysWind ab4fc8faf5 modify method name 2025-03-23 14:59:06 +08:00
MaysWind fc2c5a8e6c modify method name 2025-03-23 14:55:17 +08:00
MaysWind 1d23558dff adjust the display order 2025-03-16 22:43:26 +08:00
MaysWind ce65d0257a import transaction from beancount file 2025-03-16 22:41:28 +08:00
MaysWind 78c5b1704a import transactions from Feidee Mymoney (Elecloud) 2025-03-15 21:00:53 +08:00
MaysWind 00f8b6d950 code refactor 2025-03-15 17:27:08 +08:00
MaysWind e829bdccb5 modify struct name 2025-03-15 12:52:05 +08:00
MaysWind 6a7627e8c6 bump version to 0.9.0 2025-03-11 00:29:22 +08:00
MaysWind 6787d0591e support skip specified tests when build release 2025-03-10 22:02:08 +08:00
MaysWind d78dada5ec fix cannot filter transfer in transaction with transaction tags (#82) 2025-03-09 23:40:27 +08:00
MaysWind 74844b9a99 limit the maximum count of password / token check failures per IP/user per minute (#33) 2025-03-09 23:38:54 +08:00
MaysWind a29ff0d553 show add transaction button in desktop navigation bar (#59) 2025-03-09 20:15:04 +08:00
MaysWind 6632dd64b3 fix time zone name not display when creating a new scheduled transaction 2025-03-09 16:57:11 +08:00
MaysWind a8c912c4c2 modify text 2025-03-09 16:50:27 +08:00
MaysWind 2a02816127 fix the selected item not in the center sometimes 2025-03-09 16:42:40 +08:00
MaysWind 66b950b4aa modify text 2025-03-09 16:29:49 +08:00
MaysWind 639bd9c5cd use the new popup select dialog 2025-03-09 16:23:23 +08:00
MaysWind 2bf8c0b501 update README.md 2025-03-09 02:13:07 +08:00
MaysWind 847d5121aa show localized language name in language selection popup 2025-03-09 02:02:29 +08:00
MaysWind ab6e89594e code refactor 2025-03-09 01:30:57 +08:00
MaysWind 0662427cec show currency code in currency select 2025-03-09 01:19:14 +08:00
MaysWind d3b283f623 code refactor 2025-03-09 00:45:58 +08:00
MaysWind 385d97ba15 code refactor 2025-03-09 00:45:48 +08:00
MaysWind 09574f1c75 code refactor 2025-03-09 00:39:54 +08:00
MaysWind 34247be52c show currency code in currency selection popup 2025-03-09 00:00:36 +08:00
MaysWind 56b1e1f565 not display text after title when afterField not set 2025-03-08 22:43:18 +08:00
MaysWind 7c6a3081ee update README.md 2025-03-07 01:51:30 +08:00
MaysWind beeeb1c059 modify language select style 2025-03-07 01:47:27 +08:00
MaysWind 47f70098df update translation 2025-03-07 00:10:40 +08:00
MaysWind 4e6b708834 update translation 2025-03-06 22:40:08 +08:00
MaysWind a359b07ef3 update README.md 2025-03-06 22:39:43 +08:00
tkymmm bb0524c559 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm d70ea1987a Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 001de8a1e0 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm f045e8ffcd Add japanese 2025-03-06 13:31:47 +08:00
tkymmm f8be1222d6 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 8848fe8b33 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 32524dac56 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm a50ecf4d9c Add Japanese translation 2025-03-06 13:31:47 +08:00
tkymmm 98fda4e5d5 Add japanese .go 2025-03-06 13:31:47 +08:00
tkymmm f0c111f02a Update all_locales.go 2025-03-06 13:31:47 +08:00
MaysWind bcc36e1533 modify option default value 2025-03-05 00:35:01 +08:00
MaysWind 872639fefa support sub path 2025-03-04 23:39:16 +08:00
MaysWind e83b959930 convert transaction type in import transaction dialog 2025-03-04 01:27:51 +08:00
MaysWind 3f8de39683 update README.md 2025-03-04 01:27:39 +08:00
MaysWind 9430f57a0b import transaction from custom delimiter-separated values file 2025-03-04 01:27:08 +08:00
MaysWind 703ceb44e4 modify file name 2025-03-02 15:51:45 +08:00
MaysWind 3954db9b99 fix default value not set (#80) 2025-03-01 21:48:34 +08:00
MaysWind 7abb5972eb modify style 2025-03-01 20:38:13 +08:00
MaysWind 377a4899b7 scheduled transaction supports start time and end time (#36) 2025-02-28 00:14:52 +08:00
MaysWind d769e833e7 increase export timeout 2025-02-27 23:04:19 +08:00
MaysWind 9786f96fe5 trim trailing zero in transaction amount when importing OFX file (#73) 2025-02-27 22:11:19 +08:00
MaysWind bd2a672c12 do not set custom user agent for IMF exchange rate data source 2025-02-21 00:17:15 +08:00
MaysWind 96cb45dd45 trim redundant space 2025-02-21 00:11:29 +08:00
MaysWind cf370c083b add format string for log content 2025-02-20 00:09:12 +08:00
MaysWind 43e1780dc8 update comment 2025-02-19 23:55:25 +08:00
MaysWind 6aac810450 not select all text when user actively selects text 2025-02-19 23:34:38 +08:00
MaysWind f14c283a83 upgrade golang to 1.24.0 2025-02-19 23:19:36 +08:00
MaysWind e78b2cafb1 upgrade node.js to 22.14.0, alpine base image to 3.21.3 2025-02-19 23:19:17 +08:00
MaysWind a9eaf011cd fix the parent account not displayed in the account list in the transaction list page 2025-02-19 23:04:27 +08:00
MaysWind 7fca519fd9 fix the incorrect order in account list when there are more than one accounts with multiple sub-accounts in one category 2025-02-19 22:59:30 +08:00
MaysWind a9a37b0c97 fix the Postgres database transaction cannot continue to execute after failure (#50) 2025-02-17 00:32:53 +08:00
MaysWind 8f55bd0df1 add log 2025-02-17 00:01:24 +08:00
MaysWind 85e88949c4 fix clientSessionId not regenerated when use duplicate in desktop version 2025-02-16 23:16:59 +08:00
MaysWind 80bcebbf66 add log 2025-02-16 01:04:46 +08:00
MaysWind 7cae873830 parse split transactions in IIF file into separate transactions 2025-02-16 01:04:24 +08:00
MaysWind 7a1b27927f support two digits year 2025-02-13 23:30:43 +08:00
MaysWind 306da60752 fix wrong log content 2025-02-13 23:19:52 +08:00
MaysWind 4274b90b1e remove unused code 2025-02-12 23:26:31 +08:00
MaysWind 0b5721671d code refactor 2025-02-12 23:26:03 +08:00
MaysWind 30575d15d0 modify parameter name 2025-02-12 23:21:54 +08:00
MaysWind 0ca2f8b4a7 fix the wrong account balance in transaction edit page due to #a0e3a269a0098d05fa1a17eee4cce393869fc5cc 2025-02-12 01:25:46 +08:00
MaysWind 2e01e5530c after the search bar is focused and the screen height is reduced, let the sheet scroll to top 2025-02-12 00:56:08 +08:00
MaysWind 13cc6a2cf0 fix some content exceeds the screen in landscape mode 2025-02-11 23:35:35 +08:00
MaysWind 35ba5dcc9f disable vue-i18n legacy mode 2025-02-11 22:40:19 +08:00
MaysWind ab58109e5e account edit page displays the debt amount instead of the balance for credit card and debt accounts 2025-02-11 00:45:23 +08:00
MaysWind 18a6d25ed6 code refactor 2025-02-10 23:12:02 +08:00
MaysWind a0e3a269a0 fix the wrong display order of savings accounts and certificate of deposit accounts 2025-02-09 23:41:44 +08:00
MaysWind 1658d0758c support input page number in transaction list for desktop 2025-02-09 22:17:17 +08:00
MaysWind 6f0c59bba4 fix the menu not disappear after clicking the page number 2025-02-09 22:04:44 +08:00
MaysWind 1e98a0df55 press enter / tab to use the current input page number 2025-02-09 21:54:38 +08:00
MaysWind fa77d3e837 code refactor 2025-02-09 21:52:48 +08:00
MaysWind a21bc7aad7 code refactor 2025-02-09 21:27:19 +08:00
MaysWind e665fac956 modify style 2025-02-09 21:13:22 +08:00
MaysWind e60c633e56 duplicate transaction with time / geographic location (#36) 2025-02-09 21:11:55 +08:00
MaysWind a444526743 modify style 2025-02-09 20:14:02 +08:00
MaysWind b65a246fcb upgrade third party dependencies 2025-02-09 19:59:57 +08:00
MaysWind c5e8a50033 fix text item key 2025-02-09 19:59:24 +08:00
MaysWind 6021c24da9 format code 2025-02-09 17:50:09 +08:00
MaysWind 0e4cd10376 remove compatibility code for migration 2025-02-09 17:28:20 +08:00
MaysWind f2c043a299 code refactor 2025-02-09 17:28:20 +08:00
MaysWind 596787b998 migrate transaction list page to composition API and typescript 2025-02-09 17:28:20 +08:00
MaysWind bb3a0c4444 update README.md 2025-02-09 17:28:19 +08:00
chrgm 624b9cb20b Add German translation 2025-02-09 17:27:15 +08:00
MaysWind 5a6c25d616 tree view selection sheet supports filtering content (#38) 2025-02-07 23:25:14 +08:00
MaysWind eb178e7bed set save button disabled when tag name is empty 2025-02-07 23:24:58 +08:00
MaysWind 373d71c124 transaction tag selection sheet supports filtering content (#38) 2025-02-07 23:24:41 +08:00
MaysWind 6618cfeceb modify style 2025-02-07 21:36:56 +08:00
MaysWind 58c1382570 show hidden tag when there are no available tags 2025-02-07 00:08:42 +08:00
MaysWind 721eb122bd code refactor 2025-02-06 23:36:03 +08:00
MaysWind 95205d2f1d code refactor 2025-02-06 23:29:08 +08:00
MaysWind b6efa91879 two column list item selection supports filtering content (#38) 2025-02-06 22:37:02 +08:00
MaysWind 29d7ee09c8 code refactor 2025-02-06 21:07:26 +08:00
MaysWind 68cb5bc523 remove unused code 2025-02-06 20:44:18 +08:00
MaysWind bd96b2398a add readonly modifier 2025-02-06 20:32:52 +08:00
MaysWind 1579882475 code refactor 2025-02-06 00:15:04 +08:00
MaysWind b077b99806 do not save transaction draft when category / account / tag is same as the initial value (#37) 2025-02-05 22:38:41 +08:00
MaysWind 9797e7e58f fix the account list page shows skeleton and account info at the same time when reloading the account list for mobile version 2025-02-05 21:46:08 +08:00
MaysWind 00f62fd608 code refactor 2025-02-05 00:19:01 +08:00
MaysWind 833e767e6c migrate transaction edit page to composition API and typescript 2025-02-05 00:02:40 +08:00
MaysWind 3e7b3297aa fix the date ranges of income and expense trends in home page is wrong (#49) 2025-02-04 21:49:43 +08:00
MaysWind 8e170a69e8 migrate template list page to composition API and typescript 2025-02-04 14:38:09 +08:00
MaysWind 00d6f5d473 code refactor 2025-02-04 14:27:22 +08:00
MaysWind 3c363788d8 migrate mobile home page to composition API and typescript 2025-02-04 13:05:32 +08:00
MaysWind cc920cff9a migrate transaction template store to composition API and typescript 2025-02-04 12:45:25 +08:00
MaysWind 5d78d56f0c not allow to sort data in table when importing transactions 2025-02-04 01:36:08 +08:00
MaysWind b9b47c4428 show red checkbox when data is invalid 2025-02-04 01:32:06 +08:00
MaysWind a5382e9fdd show error when no selected file to import 2025-02-04 01:30:54 +08:00
MaysWind 376f5b2650 migrate importing transaction dialog to composition API and typescript 2025-02-04 01:29:25 +08:00
MaysWind 61c5f75006 code refactor 2025-02-03 23:10:10 +08:00
MaysWind 9a6148fe6e code refactor 2025-02-03 21:27:09 +08:00
MaysWind ff6a558e96 code refactor 2025-02-03 20:38:37 +08:00
MaysWind 3ad45bebb7 migrate transaction tag filter page to composition API and typescript 2025-02-03 20:38:30 +08:00
MaysWind 6b152bd778 code refactor 2025-02-03 16:37:11 +08:00
MaysWind aacde2dfde migrate category filter page to composition API and typescript 2025-02-03 15:21:40 +08:00
MaysWind 6971eccb22 code refactor 2025-02-03 14:48:55 +08:00
MaysWind a5e7c483ef ofx 1.x supports utf-8 encoding (#48) 2025-02-02 17:52:17 +08:00
MaysWind 319f97bf9e use QEMU 8.1.5 2025-01-31 23:00:08 +08:00
MaysWind da31a67c52 upgrade actions 2025-01-31 22:33:50 +08:00
MaysWind af355e5b85 migrate account filter page to composition API and typescript 2025-01-29 22:49:14 +08:00
MaysWind ca9fe264b4 code refactor 2025-01-29 22:31:59 +08:00
MaysWind c07e937702 disable controls when importing transactions 2025-01-28 12:22:28 +08:00
MaysWind 371b88c6cd switch to basic tab after clicking duplicate button 2025-01-28 12:05:00 +08:00
MaysWind 782bc11950 migrate transaction store to composition API and typescript 2025-01-28 11:58:43 +08:00
MaysWind 50c3fee7dc fix cannot create transaction tag in transaction edit dialog 2025-01-28 00:40:42 +08:00
MaysWind 51c4e06e59 code refactor 2025-01-28 00:17:42 +08:00
MaysWind 2a84f44f2c code refactor 2025-01-28 00:17:34 +08:00
MaysWind 4878c7258d check whether event target is input element 2025-01-27 20:36:55 +08:00
MaysWind e2a4e0cb3f migrate account list page to composition API and typescript 2025-01-27 20:36:47 +08:00
MaysWind fd3457af84 code refactor 2025-01-27 15:37:55 +08:00
MaysWind 8c0a9062a2 code refactor 2025-01-27 01:22:49 +08:00
MaysWind 10d301aa3c code refactor 2025-01-27 00:45:53 +08:00
MaysWind d7193847c5 code refactor 2025-01-27 00:45:48 +08:00
MaysWind 8ea079679c code refactor 2025-01-27 00:42:23 +08:00
MaysWind 95c7b498ff code refactor 2025-01-27 00:38:43 +08:00
MaysWind 1156499b05 code refactor 2025-01-27 00:36:34 +08:00
MaysWind e92aaffc94 make currency property required 2025-01-27 00:16:42 +08:00
MaysWind 8dc38912c9 select amount value when click the amount text box and the amount value is zero (#36, #45) 2025-01-27 00:05:52 +08:00
MaysWind d228bf12bb update code order and add missing text item 2025-01-26 23:46:53 +08:00
Artemy Egorov 7dff8a2ed5 fix: a couple of entries 2025-01-26 23:40:35 +08:00
Artemy Egorov 814fe02949 feat: full ru frontend locale 2025-01-26 23:40:35 +08:00
Artemy Egorov da702d6316 feat: update other locales 2025-01-26 23:40:35 +08:00
Artemy Egorov fd909023f9 feat: backend russian locale 2025-01-26 23:40:35 +08:00
MaysWind c94c455b8b fix wrong size of button icon in safari 2025-01-26 00:49:15 +08:00
MaysWind d39b0ee077 upgrade third party dependencies 2025-01-25 23:45:20 +08:00
MaysWind 99a2f40a4e add English hint for language option when current language is not English 2025-01-25 23:22:45 +08:00
MaysWind acaad355ed migrate trends bar chart to composition API and typescript 2025-01-25 23:22:38 +08:00
MaysWind ad4b351a32 migrate batch replace dialog to composition API and typescript 2025-01-25 20:07:19 +08:00
MaysWind 2902fae1df code refactor 2025-01-25 20:02:14 +08:00
MaysWind a0b9ca7fae migrate entry and router file to typescript 2025-01-25 16:13:55 +08:00
MaysWind 05d8f8b9ab code refactor 2025-01-25 15:05:47 +08:00
MaysWind d074a9d54a format code 2025-01-25 15:05:37 +08:00
MaysWind d0274013cf fix typo 2025-01-25 15:03:53 +08:00
MaysWind 1e0169a9b7 remove unused code 2025-01-25 15:02:59 +08:00
MaysWind c619d2ecad code refactor 2025-01-25 14:55:40 +08:00
MaysWind a27a2556aa migrate transaction statistics page to composition API and typescript 2025-01-25 14:52:57 +08:00
MaysWind 8207373a05 migrate statistics store to composition API and typescript 2025-01-24 23:16:57 +08:00
MaysWind 986fab9cbf modify struct name 2025-01-24 22:28:31 +08:00
MaysWind 2025551f3c remove unused code 2025-01-24 22:25:34 +08:00
MaysWind 3d934ab018 remove unused code 2025-01-24 22:25:04 +08:00
MaysWind 6a59ed0984 code refactor 2025-01-24 00:42:43 +08:00
MaysWind 8fce3f2bcc migrate account edit page to composition API and typescript 2025-01-24 00:42:37 +08:00
MaysWind eca0574e41 code refactor 2025-01-23 23:40:34 +08:00
MaysWind fa044f5972 code refactor 2025-01-23 23:01:16 +08:00
MaysWind 6d758f338b code refactor 2025-01-23 22:33:14 +08:00
MaysWind 28322bad5e code refactor 2025-01-23 21:57:53 +08:00
MaysWind a9805b8fff migrate mobile pie chart to composition API and typescript 2025-01-23 21:29:41 +08:00
MaysWind eb16b7fbb8 code refactor 2025-01-23 21:10:00 +08:00
MaysWind 70428b6c96 migrate desktop pie chart to composition API and typescript 2025-01-23 00:06:05 +08:00
MaysWind 85557c2879 fix "Save Display Order" still displays bug when adjusting the order and then restoring the original order in the desktop version 2025-01-22 23:12:58 +08:00
MaysWind 5bf7f77520 migrate transaction category list page to composition API and typescript 2025-01-22 23:08:18 +08:00
MaysWind 3cdc7c947f remove unused code 2025-01-22 22:09:56 +08:00
MaysWind 84fc6b2ffb migrate user 2fa setting page to composition API and typescript 2025-01-22 21:47:00 +08:00
MaysWind e7612f6f0c code refactor 2025-01-21 23:47:28 +08:00
MaysWind 58097331da code refactor 2025-01-21 23:47:22 +08:00
MaysWind e053528abf migrate session list page /user security page to composition API and typescript 2025-01-21 23:38:06 +08:00
MaysWind 568abb6e03 update transaction tag icon style 2025-01-21 00:27:45 +08:00
MaysWind 852c7899b5 modify style 2025-01-20 23:59:47 +08:00
MaysWind f4998da4cd migrate user profile page to composition API and typescript 2025-01-20 23:56:09 +08:00
MaysWind 9d9e6ef9bd migrate amount filter page to composition API and typescript 2025-01-20 22:07:47 +08:00
MaysWind fb367174b6 not show session updates toast after password modified 2025-01-20 00:41:23 +08:00
MaysWind 87566010be code refactor 2025-01-20 00:40:46 +08:00
MaysWind 2f7cdfd786 code refactor 2025-01-20 00:40:25 +08:00
MaysWind 559e8259be migrate root store to composition API and typescript 2025-01-20 00:40:02 +08:00
MaysWind 929d3febb0 remove unused code 2025-01-19 22:20:19 +08:00
MaysWind 92df98c3fb migrate signup page to composition API and typescript 2025-01-19 22:13:06 +08:00
MaysWind 019c993313 code refactor 2025-01-19 21:06:26 +08:00
MaysWind 3c370b7ac7 migrate forget password / reset password / verify email page to composition API and typescript 2025-01-19 20:57:55 +08:00
MaysWind 1afd811aa8 migrate login page to composition API and typescript 2025-01-19 20:54:10 +08:00
MaysWind 0a999d56c7 code refactor 2025-01-19 19:14:23 +08:00
MaysWind 4f6988d775 code refactor 2025-01-19 18:26:00 +08:00
MaysWind 9f2bbe527e migrate desktop home page to composition API and typescript 2025-01-19 00:48:27 +08:00
MaysWind 965be837a3 migrate transaction category edit page to composition API and typescript 2025-01-18 23:47:49 +08:00
MaysWind f5f8b9a145 code refactor 2025-01-18 23:36:02 +08:00
MaysWind f3b6c1266d code refactor 2025-01-18 23:35:43 +08:00
MaysWind c675057ab1 code refactor 2025-01-18 22:50:13 +08:00
MaysWind 20e95e35aa migrate preset dialog to composition API and typescript 2025-01-18 22:33:58 +08:00
MaysWind f22666e756 migrate account store to composition API and typescript 2025-01-18 00:37:44 +08:00
MaysWind 3567ac170a set name column length to 64 2025-01-17 22:02:33 +08:00
MiGueL0n 260bd952d4 Fix error when adding transactions 2025-01-17 20:31:05 +08:00
MiGueL0n e294f04b04 Update es.go
Change variable name from "en" to "es" to fix errors.
2025-01-17 13:04:25 +08:00
MiGueL0n 27fa5625be Backend spanish language
Sorry is my first pull 🤦‍♂️ i forgot to add Spanish in backend structure.
2025-01-17 12:09:22 +08:00
MiGueL0n 62623e6a23 Update es.json
Fix the error of the app name translated into Spanish with its original name.
2025-01-17 12:09:22 +08:00
MiGueL0n 9df436d31e Changes to do the things right 🙃 2025-01-17 12:09:22 +08:00
MiGueL0n c03f74154b Added Spanish locale options and translations 2025-01-17 12:09:22 +08:00
MaysWind 749bdfd164 migrate lib/account.js to typescript 2025-01-17 00:37:41 +08:00
MaysWind 6878d5260d code refactor 2025-01-16 23:16:20 +08:00
MaysWind 4f21762533 migrate app lock settings page to composition API and typescript 2025-01-16 22:37:35 +08:00
MaysWind adebc96637 code refactor 2025-01-16 21:35:56 +08:00
MaysWind 3a50e6d2de fix error not display in desktop 2025-01-16 21:32:20 +08:00
MaysWind b09b66adc3 migrate transaction category preset page to composition API and typescript 2025-01-16 00:43:50 +08:00
MaysWind 6ef42a9303 code refactor 2025-01-16 00:25:50 +08:00
MaysWind 922c338387 code refactor 2025-01-15 23:15:42 +08:00
MaysWind dc4310c301 code refactor 2025-01-15 22:36:38 +08:00
MaysWind 0b7fd647e6 add noImplicitOverride and noPropertyAccessFromIndexSignature compiler options 2025-01-15 22:19:28 +08:00
MaysWind 081b270f04 migrate transaction categories page to composition API and typescript 2025-01-14 23:48:06 +08:00
MaysWind 29c09cb10a migrate unlock page to composition API and typescript 2025-01-14 23:36:31 +08:00
MaysWind cd2e6c1aae migrate two column select to composition API and typescript 2025-01-14 22:23:24 +08:00
MaysWind d3e6756c22 code refactor 2025-01-14 22:20:19 +08:00
MaysWind 7d31812055 code refactor 2025-01-14 21:20:40 +08:00
MaysWind 8ce871e9bb migrate two column list item selection sheet to composition API and typescript 2025-01-14 00:38:15 +08:00
MaysWind 30125f0faa modify style 2025-01-14 00:37:46 +08:00
MaysWind f4ea9a85f0 code refactor 2025-01-14 00:14:07 +08:00
MaysWind 593e123610 remove unused code 2025-01-13 23:55:19 +08:00
MaysWind 4e8eddd868 migrate user data management page to composition API and typescript 2025-01-13 23:19:06 +08:00
MaysWind 041dbcb5c7 code refactor 2025-01-13 23:05:21 +08:00
MaysWind 215a0163b6 fix the incorrect language default time format 2025-01-13 22:30:05 +08:00
MaysWind a38867ce01 not allow to input or paste when amount input is set to readonly or disabled (#36) 2025-01-13 22:03:55 +08:00
MaysWind 9026c526f8 code refactor 2025-01-13 21:59:09 +08:00
MaysWind c1f94a4499 code refactor 2025-01-13 21:40:17 +08:00
MaysWind 2be329974e migrate transaction category store to composition API and typescript 2025-01-12 23:47:56 +08:00
MaysWind abb26ac410 migrate app settings page to composition API and typescript 2025-01-12 20:39:15 +08:00
MaysWind c5f03165bc migrate exchange rates page to composition API and typescript 2025-01-12 19:23:20 +08:00
MaysWind 41452ac20b add exception log 2025-01-12 18:16:20 +08:00
MaysWind a285707b53 migrate tree view selection sheet to composition API and typescript 2025-01-12 17:42:01 +08:00
MaysWind cc9a5eea36 display prefix in one line 2025-01-12 17:20:08 +08:00
MaysWind 89d5cc31af migrate amount input to composition API and typescript 2025-01-12 16:53:20 +08:00
MaysWind f0e11e952c remove unused code 2025-01-12 15:31:43 +08:00
MaysWind 0f8431ff5c remove space decimal separator 2025-01-12 15:29:26 +08:00
MaysWind 659b819011 code refactor 2025-01-12 15:10:22 +08:00
MaysWind 6a62cfdef7 code refactor 2025-01-12 15:10:07 +08:00
MaysWind f2ebd751d4 migrate app and user setting page framework to composition API and typescript 2025-01-12 12:55:45 +08:00
MaysWind 1414f54a12 migrate statistics setting page to composition API and typescript 2025-01-12 12:39:28 +08:00
MaysWind c3265c5bf6 migrate text size setting page to composition API and typescript 2025-01-12 11:45:14 +08:00
MaysWind 13e322bb57 code refactor 2025-01-12 10:58:12 +08:00
MaysWind 5cacfc8daf migrate income&expense overview card and monthly income&expense card to composition API and typescript 2025-01-12 02:10:40 +08:00
MaysWind 9bbe4d2dcf code refactor 2025-01-12 01:04:19 +08:00
MaysWind b517409229 migrate schedule frequency select / selection sheet to composition API and typescript 2025-01-12 01:02:03 +08:00
MaysWind 75a96e871a migrate overview list item selection sheet to composition API and typescript 2025-01-12 00:06:24 +08:00
MaysWind 395f7dfd63 migrate webauthn.js to typescript 2025-01-11 23:49:47 +08:00
MaysWind b166f6ff56 code refactor 2025-01-11 23:10:17 +08:00
MaysWind eb2b6d1002 migrate transaction tag list page to composition API and typescript 2025-01-11 23:09:03 +08:00
MaysWind 6cb045453a migrate transaction tag sheet to composition API and typescript 2025-01-11 20:12:15 +08:00
MaysWind ffae9e81a7 migrate transaction tag store to composition API and typescript 2025-01-11 19:53:09 +08:00
MaysWind dc59d3954a remove unused code 2025-01-11 18:15:10 +08:00
MaysWind 7b26eb50bf fix trend analysis not reload data when set custom month range in mobile version 2025-01-11 18:08:39 +08:00
MaysWind c0ab1ad793 fix the dark mode not take effect in mobile photo browser 2025-01-11 18:06:55 +08:00
MaysWind c73dcb51e4 migrate month range selection sheet / dialog to composition API and typescript 2025-01-11 17:57:14 +08:00
MaysWind 8c7fc0fef9 migrate datetime selection sheet to composition API and typescript 2025-01-11 16:51:56 +08:00
MaysWind 76a2e24d06 migrate datetime select to composition API and typescript 2025-01-11 15:58:09 +08:00
MaysWind 3e6a054913 migrate date range selection sheet / dialog to composition API and typescript 2025-01-11 15:44:54 +08:00
MaysWind 89b233e51b code refactor 2025-01-11 14:05:17 +08:00
MaysWind 61f26e060e code refactor 2025-01-11 02:50:59 +08:00
MaysWind 5649bb243d code refactor 2025-01-11 02:29:44 +08:00
MaysWind ea90e97f92 update unit test 2025-01-11 02:14:49 +08:00
MaysWind 3c624188d1 code refactor 2025-01-11 02:14:35 +08:00
MaysWind 04f373e931 migrate about page to composition API and typescript 2025-01-11 01:56:50 +08:00
MaysWind b2e36a24fd migrate mobile ui utils to typescript 2025-01-11 00:53:22 +08:00
MaysWind 8da3d2aa35 migrate i18n helper.js some code to typescript and migrate vue file to composition API and typescript 2025-01-11 00:49:21 +08:00
MaysWind 25c8b9baf8 migrate overview store to composition API and typescript 2025-01-09 00:22:28 +08:00
MaysWind 1555052e1d migrate index.js to typescript 2025-01-08 22:59:58 +08:00
MaysWind b1fbf91d6e migrate color/icon selection sheet to composition API and typescript 2025-01-08 22:52:13 +08:00
MaysWind 5dfac0c085 package locales/helper.ts to commons.js 2025-01-07 22:07:36 +08:00
MaysWind cb142a65f3 move file 2025-01-07 00:09:28 +08:00
MaysWind b0a9b2366e code refactor 2025-01-06 23:52:54 +08:00
MaysWind ed897d4105 change file name 2025-01-06 23:12:05 +08:00
MaysWind 2b71723ba1 migrate number pad sheet to composition API and typescript 2025-01-06 22:41:55 +08:00
MaysWind 60ba3b7977 format code 2025-01-06 22:03:28 +08:00
MaysWind e0198da52c fix not show selected transaction category / account / tag 2025-01-06 21:58:52 +08:00
MaysWind 6365805715 code refactor 2025-01-06 21:49:49 +08:00
MaysWind 5e7e3696bf update comments 2025-01-06 21:43:50 +08:00
MaysWind 166fae425d code refactor 2025-01-06 21:42:36 +08:00
MaysWind f56bef40d8 code refactor 2025-01-05 23:54:35 +08:00
MaysWind ad1eec7d47 migrate exchange rates store to composition API and typescript 2025-01-05 23:22:59 +08:00
MaysWind 5b241d2547 migrate 2fa auth store to composition API and typescript 2025-01-05 23:01:32 +08:00
MaysWind 92a626fb21 migrate token store to composition API and typescript 2025-01-05 22:56:07 +08:00
MaysWind 5171f23c09 migrate user store to composition API and typescript 2025-01-05 22:54:53 +08:00
MaysWind 6dc0ebcac6 code refactor 2025-01-05 21:39:39 +08:00
MaysWind 49f1f3c86b code refactor 2025-01-05 20:43:19 +08:00
MaysWind 53ab441486 remove unused code 2025-01-05 19:46:22 +08:00
MaysWind 0e422b5a8f migrate settings store to composition API and typescript 2025-01-05 19:45:55 +08:00
MaysWind 4f51480af9 migrate map sheet to composition API and typescript 2025-01-05 17:56:58 +08:00
MaysWind fb8fbbcf70 migrate pin code input sheet to composition API and typescript 2025-01-05 17:46:43 +08:00
MaysWind 5256eff88d code refactor 2025-01-05 17:24:22 +08:00
MaysWind 7c40157cba migrate switch to mobile dialog to composition API and typescript 2025-01-05 17:07:46 +08:00
MaysWind 454e97c9f1 migrate services.js to ts 2025-01-05 17:00:26 +08:00
MaysWind 41d34af4c7 migrate userstat.js to ts 2025-01-05 14:07:47 +08:00
MaysWind a13f5bfb10 support base components folder 2025-01-05 13:53:08 +08:00
MaysWind da06fe4a7b migrate color / icon select to typescript 2025-01-05 13:22:35 +08:00
MaysWind 061ea6aab4 code refactor 2025-01-05 13:14:08 +08:00
MaysWind a19cc81391 code refactor 2025-01-05 13:13:21 +08:00
MaysWind 871164b969 code refactor 2025-01-05 03:12:22 +08:00
MaysWind 16fa77eb09 migrate map code to typescript 2025-01-05 03:12:16 +08:00
MaysWind a46399cbaf migrate setting.js and logger.js to ts 2025-01-04 23:42:57 +08:00
MaysWind a9e50d29d3 fix display incorrect month 2025-01-04 23:34:20 +08:00
MaysWind e7d7f217a9 migrate consts/statistics.js to ts 2025-01-04 23:33:17 +08:00
MaysWind 000c2b9ab0 migrate desktop ui utils to typescript 2025-01-04 20:52:42 +08:00
MaysWind 37fdb161ea migrate information / password input / passcode input sheet to composition API and typescript 2025-01-04 19:29:34 +08:00
MaysWind 2d923bbdc9 migrate confirm dialog to composition API and typescript 2025-01-04 19:29:25 +08:00
MaysWind 07c55de024 migrate steps bar to composition API and typescript 2025-01-04 19:02:37 +08:00
MaysWind 30c463627a code refactor 2025-01-04 19:02:30 +08:00
MaysWind 5eec635146 migrate snack bar to composition API and typescript 2025-01-04 19:02:21 +08:00
MaysWind 229d9c76c3 migrate item icon to composition API and typescript 2025-01-04 17:49:38 +08:00
MaysWind 0119eadc14 code refactor 2025-01-04 17:20:36 +08:00
MaysWind c1c656ab7e add missing type 2025-01-04 17:20:28 +08:00
MaysWind e4a50bcd60 migrate to composition API and typescript 2025-01-04 17:20:00 +08:00
MaysWind d18e6df1c0 pin code input supports pressing enter to confirm and pressing tab to switch to the next component 2025-01-04 16:33:33 +08:00
MaysWind af9aa726f4 migrate pin code input to composition API and typescript 2025-01-04 16:33:18 +08:00
MaysWind 27f8c90dae migrate numeral.js to ts 2025-01-04 14:47:52 +08:00
MaysWind b9a3c384d9 remove unused code 2025-01-04 14:05:24 +08:00
MaysWind eed7085756 code refactor 2025-01-04 14:05:16 +08:00
MaysWind abb0c2ad16 fix cannot click set custom date range in date filter drop menu 2025-01-04 00:48:50 +08:00
MaysWind 9f7b40381c migrate lib/datetime.js to ts 2025-01-04 00:18:35 +08:00
MaysWind ad9a390b58 migrate consts/datetime.js to ts 2025-01-02 00:39:56 +08:00
MaysWind 5525635df1 bump year 2025-01-01 01:43:26 +08:00
MaysWind 863e0205ff code refactor 2024-12-30 23:11:03 +08:00
MaysWind 2560a70e5e migrate to typescript 2024-12-30 00:56:48 +08:00
MaysWind b638a73e4d modify style 2024-12-29 22:46:48 +08:00
MaysWind 0a9cea4df3 do not check whether the date range is billing cycle when no statement date 2024-12-23 08:50:24 +08:00
MaysWind 15a98c3eac add missing router path 2024-12-22 22:05:05 +08:00
MaysWind 493c16087d add missing router path 2024-12-22 21:52:56 +08:00
MaysWind dd155a0f63 add gitea deploy action 2024-12-22 17:21:53 +08:00
MaysWind 9ce1c8d397 upgrade third party dependencies 2024-12-22 12:09:06 +08:00
MaysWind 3040435c06 upgrade third party dependencies 2024-12-22 11:48:34 +08:00
MaysWind 685f93f05e upgrade node.js to 22.12.0 2024-12-22 11:34:23 +08:00
MaysWind d465d9da1a add sample case for not showing currency option 2024-12-22 11:25:23 +08:00
MaysWind 50c2766014 format file 2024-12-22 00:09:17 +08:00
MaysWind 4e1cbf13c6 bump version to 0.8.0 2024-12-21 23:57:06 +08:00
724 changed files with 162690 additions and 47817 deletions
+17
View File
@@ -0,0 +1,17 @@
name: Deploy Docker Image
on:
workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Execute custom script
run: |
cat >> deploy.sh <<EOF
#!/bin/sh
${{ vars.CUSTOM_DEPLOY_SCRIPTS }}
EOF
chmod +x deploy.sh
./deploy.sh
+10 -5
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
@@ -24,10 +24,12 @@ jobs:
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Set up the environment
run: |
@@ -44,7 +46,7 @@ jobs:
chmod +x docker/custom-frontend-pre-setup.sh
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
@@ -52,5 +54,8 @@ jobs:
push: true
build-args: |
RELEASE_BUILD=1
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+10 -6
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
@@ -24,10 +24,12 @@ jobs:
type=sha,format=short,prefix=SNAPSHOT-
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Set up the environment
run: |
@@ -44,13 +46,15 @@ jobs:
chmod +x docker/custom-frontend-pre-setup.sh
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: ${{ vars.BUILD_SNAPSHOT_PLATFORMS }}
push: true
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+70
View File
@@ -0,0 +1,70 @@
name: Bug Report
description: Report a bug in ezBookkeeping
labels: bug
body:
- type: checkboxes
id: checkboxes
attributes:
label: Before You Submit
description: Please check whether the following items have been completed.
options:
- label: I've already checked this bug hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
required: true
- type: textarea
id: description
attributes:
label: Description
description: Please provide a brief description of this bug.
validations:
required: true
- type: textarea
id: reproduction-steps
attributes:
label: Steps to reproduce
description: Please describe the steps to reproduce this bug.
placeholder: |
1.
2.
3.
validations:
required: true
- type: input
id: ezbookkeeping-version
attributes:
label: ezBookkeeping Version
description: ezBookkeeping version and commit hash of your instance, e.g. "v1.0.0 (20e2444)"
validations:
required: true
- type: input
id: server-os
attributes:
label: Server Operating System
description: The operating system information you are using to deploy ezBookkeeping, e.g "Debian GNU/Linux 11 amd64"
- type: input
id: server-database
attributes:
label: Database
description: The database system you are using, e.g. "MariaDB v11.7.2"
- type: dropdown
id: reproduce-on-demo-site
attributes:
label: Can you reproduce this bug on the ezBookkeeping demo site?
description: |
ezBookkeeping demo site: https://ezbookkeeping-demo.mayswind.net/
options:
- "No"
- "Yes"
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional information
description: If you can, provide any related screenshots or logs here.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,26 @@
name: Feature Request
description: Request a feature or enhancement for ezBookkeeping
labels: enhancement
body:
- type: checkboxes
id: checkboxes
attributes:
label: Before You Submit
description: Please check whether the following items have been completed.
options:
- label: I've already checked this request hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
required: true
- type: textarea
id: description
attributes:
label: Feature Description
description: Please describe your feature request.
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional information
description: If you can, provide any other context or screenshots about this feature request here.
@@ -1,4 +1,4 @@
name: Docker Snapshot
name: Build for Non-Main Branches
on:
push:
@@ -6,25 +6,27 @@ on:
- main
jobs:
build:
build-linux-docker:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: linux/amd64
push: false
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
+224
View File
@@ -0,0 +1,224 @@
name: Build Release
on:
push:
tags:
- "v*.*.*"
jobs:
build-linux-docker:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
RELEASE_BUILD=1
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
upload-linux-artifact:
needs: build-linux-docker
runs-on: ubuntu-latest
strategy:
matrix:
include:
- arch: linux/amd64
arch_alias: linux-amd64
- arch: linux/arm64/v8
arch_alias: linux-arm64
- arch: linux/arm/v7
arch_alias: linux-armv7
- arch: linux/arm/v6
arch_alias: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Pull and save packaged files for ${{ matrix.arch }}
run: |
VERSION=${{ needs.build-linux-docker.outputs.image-tag }}
IMAGE=${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${VERSION}
docker pull --platform ${{ matrix.arch }} ${IMAGE}
cid=$(docker create "${IMAGE}")
docker cp ${cid}:/ezbookkeeping ./ezbookkeeping
docker rm ${cid}
cd ezbookkeeping
tar -czf ../ezbookkeeping-v${VERSION}-${{ matrix.arch_alias }}.tar.gz *
cd ..
rm -rf ezbookkeeping
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-${{ matrix.arch_alias }}
path: ezbookkeeping-${{ github.ref_name }}-${{ matrix.arch_alias }}.tar.gz
if-no-files-found: error
build-and-upload-windows-package:
needs: upload-linux-artifact
runs-on: windows-latest
env:
GO_VERSION: "1.25.1"
MINGW_VERSION: "14.2.0"
MINGW_REVISION: "v12-rev2"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-amd64
path: artifacts
- name: Extract frontend files from linux-amd64 package
run: |
New-Item -ItemType Directory -Path package
tar -xzf (Get-ChildItem artifacts\ezbookkeeping-${{ github.ref_name }}-linux-amd64.tar.gz) -C package
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Install MinGW
run: |
$mingwVersion = "${{ env.MINGW_VERSION }}"
$mingwRevision = "${{ env.MINGW_REVISION }}"
$url = "https://github.com/niXman/mingw-builds-binaries/releases/download/${mingwVersion}-rt_${mingwRevision}/x86_64-${mingwVersion}-release-posix-seh-ucrt-rt_${mingwRevision}.7z"
$archive = "C:\mingw.7z"
$mingwDir = "C:\mingw64"
Write-Host "Downloading MinGW from ${url}"
Invoke-WebRequest -Uri ${url} -OutFile ${archive}
Remove-Item -Recurse -Force ${mingwDir}
New-Item -ItemType Directory -Path ${mingwDir}
Write-Host "Extracting MinGW to ${mingwDir}"
7z x ${archive} -oC:\
"${mingwDir}\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Build backend for windows-x64
env:
RELEASE_BUILD: "1"
BUILD_PIPELINE: "1"
CHECK_3RD_API: ${{ vars.CHECK_3RD_API }}
SKIP_TESTS: ${{ vars.SKIP_TESTS }}
run: |
.\build.ps1 backend
- name: Package Windows build
run: |
New-Item -ItemType Directory -Path "ezbookkeeping"
New-Item -ItemType Directory -Path "ezbookkeeping\data"
New-Item -ItemType Directory -Path "ezbookkeeping\storage"
New-Item -ItemType Directory -Path "ezbookkeeping\log"
Copy-Item ezbookkeeping.exe -Destination ezbookkeeping\
Copy-Item -Recurse -Force package\public -Destination ezbookkeeping\public
Copy-Item -Recurse -Force conf -Destination ezbookkeeping\conf
Copy-Item -Recurse -Force templates -Destination ezbookkeeping\templates
Copy-Item .\LICENSE -Destination ezbookkeeping\
Push-Location ezbookkeeping
7z a -r -tzip -mx9 ..\ezbookkeeping-${{ github.ref_name }}-windows-x64.zip *
Pop-Location
Remove-Item -Recurse -Force ezbookkeeping
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-windows-x64
path: ezbookkeeping-${{ github.ref_name }}-windows-x64.zip
if-no-files-found: error
publish-release:
runs-on: ubuntu-latest
needs:
- upload-linux-artifact
- build-and-upload-windows-package
steps:
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-amd64
path: ./release-files
- name: Download linux-arm64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-arm64
path: ./release-files
- name: Download linux-armv6 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-armv6
path: ./release-files
- name: Download linux-armv7 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-armv7
path: ./release-files
- name: Download windows-x64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-windows-x64
path: ./release-files
- name: Publish Release ${{ github.ref_name }}
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
files: ./release-files/*
draft: true
+178
View File
@@ -0,0 +1,178 @@
name: Build Snapshot
on:
push:
branches:
- main
jobs:
build-linux-docker:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}.${{ github.run_id }}
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
type=raw,value=latest-snapshot
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
upload-linux-artifact:
needs: build-linux-docker
runs-on: ubuntu-latest
strategy:
matrix:
include:
- arch: linux/amd64
arch_alias: linux-amd64
- arch: linux/arm64/v8
arch_alias: linux-arm64
- arch: linux/arm/v7
arch_alias: linux-armv7
- arch: linux/arm/v6
arch_alias: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Pull and save packaged files for ${{ matrix.arch }}
run: |
TAG=${{ needs.build-linux-docker.outputs.image-tag }}
IMAGE=${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${TAG}
docker pull --platform ${{ matrix.arch }} ${IMAGE}
cid=$(docker create "${IMAGE}")
docker cp ${cid}:/ezbookkeeping ./ezbookkeeping
docker rm ${cid}
cd ezbookkeeping
tar -czf ../ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}.tar.gz *
cd ..
rm -rf ezbookkeeping
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}
path: ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}.tar.gz
if-no-files-found: error
build-and-upload-windows-package:
needs: upload-linux-artifact
runs-on: windows-latest
env:
GO_VERSION: "1.25.1"
MINGW_VERSION: "14.2.0"
MINGW_REVISION: "v12-rev2"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-dev-${{ github.run_id }}-linux-amd64
path: artifacts
- name: Extract frontend files from linux-amd64 package
run: |
New-Item -ItemType Directory -Path package
tar -xzf (Get-ChildItem artifacts\ezbookkeeping-dev-${{ github.run_id }}-linux-amd64.tar.gz) -C package
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Install MinGW
run: |
$mingwVersion = "${{ env.MINGW_VERSION }}"
$mingwRevision = "${{ env.MINGW_REVISION }}"
$url = "https://github.com/niXman/mingw-builds-binaries/releases/download/${mingwVersion}-rt_${mingwRevision}/x86_64-${mingwVersion}-release-posix-seh-ucrt-rt_${mingwRevision}.7z"
$archive = "C:\mingw.7z"
$mingwDir = "C:\mingw64"
Write-Host "Downloading MinGW from ${url}"
Invoke-WebRequest -Uri ${url} -OutFile ${archive}
Remove-Item -Recurse -Force ${mingwDir}
New-Item -ItemType Directory -Path ${mingwDir}
Write-Host "Extracting MinGW to ${mingwDir}"
7z x ${archive} -oC:\
"${mingwDir}\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Build backend for windows-x64
env:
BUILD_PIPELINE: "1"
CHECK_3RD_API: ${{ vars.CHECK_3RD_API }}
SKIP_TESTS: ${{ vars.SKIP_TESTS }}
run: |
.\build.ps1 backend
- name: Package Windows build
run: |
New-Item -ItemType Directory -Path "ezbookkeeping"
New-Item -ItemType Directory -Path "ezbookkeeping\data"
New-Item -ItemType Directory -Path "ezbookkeeping\storage"
New-Item -ItemType Directory -Path "ezbookkeeping\log"
Copy-Item ezbookkeeping.exe -Destination ezbookkeeping\
Copy-Item -Recurse -Force package\public -Destination ezbookkeeping\public
Copy-Item -Recurse -Force conf -Destination ezbookkeeping\conf
Copy-Item -Recurse -Force templates -Destination ezbookkeeping\templates
Copy-Item .\LICENSE -Destination ezbookkeeping\
Push-Location ezbookkeeping
7z a -r -tzip -mx9 ..\ezbookkeeping-dev-${{ github.run_id }}-windows-x64.zip *
Pop-Location
Remove-Item -Recurse -Force ezbookkeeping
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: ezbookkeeping-dev-${{ github.run_id }}-windows-x64
path: ezbookkeeping-dev-${{ github.run_id }}-windows-x64.zip
if-no-files-found: error
-52
View File
@@ -1,52 +0,0 @@
name: Docker Release
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
file: Dockerfile
context: .
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
RELEASE_BUILD=1
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-51
View File
@@ -1,51 +0,0 @@
name: Docker Snapshot
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
type=raw,value=latest-snapshot
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
file: Dockerfile
context: .
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3
View File
@@ -144,3 +144,6 @@ dist/
# Visual Studio Code
.vscode/
*.code-workspace
# Roo Code
.roo/
+9 -3
View File
@@ -1,8 +1,12 @@
# Build backend binary file
FROM golang:1.23.4-alpine3.21 AS be-builder
FROM golang:1.25.1-alpine3.22 AS be-builder
ARG RELEASE_BUILD
ARG BUILD_PIPELINE
ARG CHECK_3RD_API
ARG SKIP_TESTS
ENV RELEASE_BUILD=$RELEASE_BUILD
ENV BUILD_PIPELINE=$BUILD_PIPELINE
ENV CHECK_3RD_API=$CHECK_3RD_API
ENV SKIP_TESTS=$SKIP_TESTS
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
COPY . .
@@ -11,9 +15,11 @@ RUN apk add git gcc g++ libc-dev
RUN ./build.sh backend
# Build frontend files
FROM --platform=$BUILDPLATFORM node:20.18.1-alpine3.21 AS fe-builder
FROM --platform=$BUILDPLATFORM node:24.7.0-alpine3.22 AS fe-builder
ARG RELEASE_BUILD
ARG BUILD_PIPELINE
ENV RELEASE_BUILD=$RELEASE_BUILD
ENV BUILD_PIPELINE=$BUILD_PIPELINE
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
COPY . .
RUN docker/frontend-build-pre-setup.sh
@@ -21,7 +27,7 @@ RUN apk add git
RUN ./build.sh frontend
# Package docker image
FROM alpine:3.21.0
FROM alpine:3.22.1
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
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2024 MaysWind (i@mayswind.net)
Copyright (c) 2020-2025 MaysWind (i@mayswind.net)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+93 -35
View File
@@ -1,37 +1,56 @@
# ezBookkeeping
[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
[![Latest Build](https://img.shields.io/github/actions/workflow/status/mayswind/ezbookkeeping/docker-snapshot.yml?branch=main)](https://github.com/mayswind/ezbookkeeping/actions)
[![Go Report](https://goreportcard.com/badge/github.com/mayswind/ezbookkeeping)](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
[![Latest Docker Image Size](https://img.shields.io/docker/image-size/mayswind/ezbookkeeping.svg?style=flat)](https://hub.docker.com/r/mayswind/ezbookkeeping)
[![Latest Release](https://img.shields.io/github/release/mayswind/ezbookkeeping.svg?style=flat)](https://github.com/mayswind/ezbookkeeping/releases)
[![Latest Build](https://img.shields.io/github/actions/workflow/status/mayswind/ezbookkeeping/build-snapshot.yml?branch=main)](https://github.com/mayswind/ezbookkeeping/actions)
[![Latest Docker Image Size](https://img.shields.io/docker/image-size/mayswind/ezbookkeeping.svg?style=flat)](https://hub.docker.com/r/mayswind/ezbookkeeping)
[![Docker Pulls](https://img.shields.io/docker/pulls/mayswind/ezbookkeeping)](https://hub.docker.com/r/mayswind/ezbookkeeping)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mayswind/ezbookkeeping)
[![Recommend By HelloGitHub](https://api.hellogithub.com/v1/widgets/recommend.svg?rid=ded5af09da574ec1811ddb154f1b2093&claim_uid=LT7EZxeBukCnh0K)](https://hellogithub.com/en/repository/mayswind/ezbookkeeping)
[![Trending](https://trendshift.io/api/badge/repositories/12917)](https://trendshift.io/repositories/12917)
## Introduction
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including sqlite and mysql. With docker, you can just deploy it via one command without complicated configuration.
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It's easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient and highly scalable, it can run smoothly on devices as small as a Raspberry Pi, or scale up to NAS, MicroServers, and even large cluster environments.
Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
## Features
1. Open source & Self-hosted
2. Lightweight & Fast
3. Easy to install
* Docker support
* Multiple database support (SQLite, MySQL, PostgreSQL, etc.)
* Multiple operation system & hardware support (Windows, macOS, Linux & x86, amd64, ARM)
4. User-friendly interface
* Both desktop and mobile UI
* Close to native app experience (for mobile device)
* Two-level account & two-level category support
* Plentiful preset categories
* Geographic location and map support
* Searching & filtering history records
* Data statistics
* Dark theme
5. Multiple currency support & automatically updating exchange rates
6. Multiple timezone support
7. Multi-language support
8. Two-factor authentication
9. Application lock (PIN code / WebAuthn)
10. Data import & export
- **Open Source & Self-Hosted**
- Built for privacy and control
- **Lightweight & Fast**
- Optimized for performance, runs smoothly even on low-resource environments
- **Easy Installation**
- Docker-ready
- Supports SQLite, MySQL, PostgreSQL
- Cross-platform (Windows, macOS, Linux)
- Works on x86, amd64, ARM architectures
- **User-Friendly Interface**
- UI optimized for both mobile and desktop
- PWA support for native-like mobile experience
- Dark mode
- **AI-Powered Features**
- Receipt image recognition
- Supports MCP (Model Context Protocol) for AI integration
- **Powerful Bookkeeping**
- Two-level accounts and categories
- Attach images to transactions
- Location tracking with maps
- Recurring transactions
- Advanced filtering, search, visualization, and analysis
- **Localization & Globalization**
- Multi-language and multi-currency support
- Automatic exchange rates
- Multi-timezone awareness
- Custom formats for dates, numbers, and currencies
- **Security**
- Two-factor authentication (2FA)
- Login rate limiting
- Application lock (PIN code / WebAuthn)
- **Data Import/Export**
- Supports CSV, OFX, QFX, QIF, IIF, Camt.053, MT940, GnuCash, Firefly III, Beancount, and more
## Screenshots
### Desktop Version
@@ -41,19 +60,19 @@ Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-dem
[![ezBookkeeping](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)
## Installation
### Ship with docker
### Run with Docker
Visit [Docker Hub](https://hub.docker.com/r/mayswind/ezbookkeeping) to see all images and tags.
Latest Release:
**Latest Release:**
$ docker run -p8080:8080 mayswind/ezbookkeeping
Latest Daily Build:
**Latest Daily Build:**
$ docker run -p8080:8080 mayswind/ezbookkeeping:latest-snapshot
### Install from binary
Latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://github.com/mayswind/ezbookkeeping/releases)
### Install from Binary
Download the latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://github.com/mayswind/ezbookkeeping/releases)
**Linux / macOS**
@@ -63,9 +82,9 @@ Latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://git
> .\ezbookkeeping.exe server run
ezBookkeeping will listen at port 8080 as default. Then you can visit `http://{YOUR_HOST_ADDRESS}:8080/` .
By default, ezBookkeeping listens on port 8080. You can then visit `http://{YOUR_HOST_ADDRESS}:8080/` .
### Build from source
### Build from Source
Make sure you have [Golang](https://golang.org/), [GCC](http://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
**Linux / macOS**
@@ -78,17 +97,56 @@ All the files will be packaged in `ezbookkeeping.tar.gz`.
> .\build.bat package -o ezbookkeeping.zip
or
PS > .\build.ps1 package -Output ezbookkeeping.zip
All the files will be packaged in `ezbookkeeping.zip`.
You can also build docker image, make sure you have [docker](https://www.docker.com/) installed, then follow these steps:
You can also build a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps:
**Linux**
$ ./build.sh docker
## Documents
## Contributing
We welcome contributions of all kinds.
Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues)
Want to contribute code? Feel free to fork and send a pull request.
Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated.
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who've already helped.
## Translating
Help make ezBookkeeping accessible to users around the world. If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating).
Currently available translations:
| Tag | Language | Contributors |
| --- | --- | --- |
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
| en | English | / |
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon) |
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
| it | Italiano | [@waron97](https://github.com/waron97) |
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
| nl | Nederlands | [@automagic](https://github.com/automagics) |
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
| ru | Русский | [@artegoser](https://github.com/artegoser) |
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
| zh-Hans | 中文 (简体) | / |
| zh-Hant | 中文 (繁體) | / |
Don't see your language? Help us add it.
## Documentation
1. [English](http://ezbookkeeping.mayswind.net)
1. [简体中文 (Simplified Chinese)](http://ezbookkeeping.mayswind.net/zh_Hans)
1. [中文 (简体)](http://ezbookkeeping.mayswind.net/zh_Hans)
## License
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
+13 -2
View File
@@ -112,7 +112,7 @@ goto :pre_parse_args
set VERSION=%VERSION: =%
set VERSION=%VERSION:,=%
set VERSION=%VERSION:"=%
for /f %%x in ('git rev-parse --short HEAD') do set "COMMIT_HASH=%%x"
for /f %%x in ('git rev-parse --short^=7 HEAD') do set "COMMIT_HASH=%%x"
call :set_unixtime BUILD_UNIXTIME
call :set_date BUILD_DATE
@@ -191,6 +191,17 @@ goto :pre_parse_args
)
)
if "%NO_TEST%"=="0" (
echo Executing frontend unit testing...
call npm run test
if !errorlevel! neq 0 (
call :echo_red "Error: Failed to pass unit testing"
goto :end
)
)
endlocal
echo Building frontend files(%RELEASE_TYPE%)...
@@ -250,7 +261,7 @@ goto :pre_parse_args
goto :end
)
call 7z a -r -tzip -mx9 ..\%package_file_name% package *
call 7z a -r -tzip -mx9 ..\%package_file_name% *
cd ..
endlocal
+231
View File
@@ -0,0 +1,231 @@
param(
[string]$Type,
[switch]$NoLint,
[switch]$NoTest,
[string]$Output,
[switch]$Release,
[switch]$Help
)
$script:SkipTests = $env:SKIP_TESTS
$script:ReleaseType = "unknown"
$script:Version = ""
$script:CommitHash = ""
$script:BuildUnixTime = ""
$script:BuildDate = ""
function Write-Red($msg) {
Write-Host $msg -ForegroundColor Red
}
function Check-Dependency {
param([string[]]$commands)
foreach ($cmd in $commands) {
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
Write-Red "Error: `"$cmd`" is required."
exit 127
}
}
}
function Show-Help {
Write-Host "ezBookkeeping build script for Windows PowerShell"
Write-Host ""
Write-Host "Usage:"
Write-Host " build.ps1 type [options]"
Write-Host ""
Write-Host "Types:"
Write-Host " backend Build backend binary file"
Write-Host " frontend Build frontend files"
Write-Host " package Build package archive"
Write-Host ""
Write-Host "Options:"
Write-Host " -Release Build release (The script will use environment variable `"RELEASE_BUILD`" to detect whether this is release building by default)"
Write-Host " -Output <filename> Package file name (for `"package`" type only)"
Write-Host " -NoLint Do not execute lint check before building"
Write-Host " -NoTest Do not execute unit testing before building (You can use environment variable `"SKIP_TESTS`" to skip specified tests)"
Write-Host " -Help Show help"
}
function Parse-Args {
if (-not $Type) {
Show-Help
exit 0
}
if ($Release -or $env:RELEASE_BUILD) {
$script:ReleaseType = "release"
} else {
$script:ReleaseType = "snapshot"
}
}
function Check-Type-Dependencies {
Check-Dependency "git"
switch ($Type.ToLower()) {
"backend" {
Check-Dependency "go","gcc"
}
"frontend" {
Check-Dependency "node","npm"
}
"package" {
Check-Dependency "go","gcc","node","npm","7z"
}
}
}
function Set-Build-Parameters {
$script:Version = (Get-Content package.json | ConvertFrom-Json).version
$script:CommitHash = git rev-parse --short=7 HEAD
$script:BuildUnixTime = [int][double]::Parse((Get-Date -UFormat %s))
$script:BuildDate = Get-Date -Format "yyyyMMdd"
}
function Build-Backend {
Write-Host "Pulling backend dependencies..."
go get .
if (-not $NoLint) {
Write-Host "Executing backend lint checking..."
go vet -v .\...
if ($LASTEXITCODE -ne 0) {
Write-Red "Error: Failed to pass lint checking"
exit 1
}
}
if (-not $NoTest) {
Write-Host "Executing backend unit testing..."
go clean -cache
if (-not $SkipTests) {
go test .\... -v
} else {
Write-Host "(Skip unit test `"$SkipTests`")"
go test .\... -v -skip "$SkipTests"
}
if ($LASTEXITCODE -ne 0) {
Write-Red "Error: Failed to pass unit testing"
exit 1
}
}
$backend_build_extra_arguments = "-X main.Version=$Version "
$backend_build_extra_arguments = "$backend_build_extra_arguments -X main.CommitHash=$CommitHash"
if (-not $Release) {
$backend_build_extra_arguments += " -X main.BuildUnixTime=$BuildUnixTime"
}
Write-Host "Building backend binary file ($ReleaseType)..."
$env:CGO_ENABLED = 1
go build -a -v -trimpath -tags timetzdata -ldflags "-w -s -linkmode external -extldflags '-static' $backend_build_extra_arguments" -o ezbookkeeping.exe ezbookkeeping.go
Remove-Item Env:\CGO_ENABLED -ErrorAction SilentlyContinue
}
function Build-Frontend {
Write-Host "Pulling frontend dependencies..."
npm install
if (-not $NoLint) {
Write-Host "Executing frontend lint checking..."
npm run lint
if ($LASTEXITCODE -ne 0) {
Write-Red "Error: Failed to pass lint checking"
exit 1
}
}
if (-not $NoTest) {
Write-Host "Executing frontend unit testing..."
npm run test
if ($LASTEXITCODE -ne 0) {
Write-Red "Error: Failed to pass unit testing"
exit 1
}
}
Write-Host "Building frontend files ($ReleaseType)..."
if (-not $Release) {
$env:buildUnixTime = $BuildUnixTime
npm run build
Remove-Item Env:\buildUnixTime -ErrorAction SilentlyContinue
} else {
npm run build
}
}
function Build-Package {
$packageFileName = "ezbookkeeping-$Version"
if (-not $Release) {
$packageFileName = "$packageFileName-$BuildDate"
}
$packageFileName = "$packageFileName-windows.zip"
if ($Output) {
$packageFileName = $Output
}
Write-Host "Building package archive '$packageFileName' ($ReleaseType)..."
Build-Backend
Build-Frontend
Remove-Item package -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path "package"
New-Item -ItemType Directory -Path "package\data"
New-Item -ItemType Directory -Path "package\storage"
New-Item -ItemType Directory -Path "package\log"
Copy-Item ezbookkeeping.exe package\
Copy-Item dist package\public -Recurse
Copy-Item conf package\conf -Recurse
Copy-Item templates package\templates -Recurse
Copy-Item LICENSE package\
Push-Location package
7z a -r -tzip -mx9 "..\$packageFileName" *
Pop-Location
}
function Main {
if ($Help) {
Show-Help
exit 0
}
Parse-Args
Check-Type-Dependencies
Set-Build-Parameters
switch ($Type) {
"backend" {
Build-Backend
}
"frontend" {
Build-Frontend
}
"package" {
Build-Package
}
default {
Write-Red "Invalid type: $Type"
Show-Help
exit 2
}
}
}
Main
+12 -1
View File
@@ -117,7 +117,7 @@ check_type_dependencies() {
set_build_parameters() {
VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')"
COMMIT_HASH="$(git rev-parse --short HEAD)"
COMMIT_HASH="$(git rev-parse --short=7 HEAD)"
BUILD_UNIXTIME="$(date '+%s')"
}
@@ -179,6 +179,17 @@ build_frontend() {
fi
fi
if [ "$NO_TEST" = "0" ]; then
echo "Executing frontend unit testing..."
npm run test
if [ "$?" != "0" ]; then
echo_red "Error: Failed to pass unit testing"
exit 1
fi
fi
echo "Building frontend files ($RELEASE_TYPE)..."
if [ "$RELEASE" = "0" ]; then
+5 -3
View File
@@ -1,14 +1,16 @@
package cmd
import (
"github.com/urfave/cli/v2"
"context"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
)
func bindAction(fn core.CliHandlerFunc) cli.ActionFunc {
return func(cliCtx *cli.Context) error {
c := core.WrapCilContext(cliCtx)
return func(ctx context.Context, cmd *cli.Command) error {
c := core.WrapCilContext(ctx, cmd)
return fn(c)
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ package cmd
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/cron"
@@ -14,7 +14,7 @@ import (
var CronJobs = &cli.Command{
Name: "cron",
Usage: "ezBookkeeping cron job utilities",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "list",
Usage: "List all enabled cron jobs",
+18 -2
View File
@@ -1,7 +1,7 @@
package cmd
import (
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/datastore"
@@ -13,7 +13,7 @@ import (
var Database = &cli.Command{
Name: "database",
Usage: "ezBookkeeping database maintenance",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "update",
Usage: "Update database structure",
@@ -133,5 +133,21 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error {
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction picture table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserCustomExchangeRate))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user custom exchange rate table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserApplicationCloudSetting))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user application cloud settings table maintained successfully")
return nil
}
+20
View File
@@ -9,6 +9,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
"github.com/mayswind/ezbookkeeping/pkg/llm"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/settings"
@@ -90,6 +91,15 @@ func initializeSystem(c *core.CliContext) (*settings.Config, error) {
return nil, err
}
err = llm.InitializeLargeLanguageModelProvider(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes large language model provider failed, because %s", err.Error())
}
return nil, err
}
err = uuid.InitializeUuidGenerator(config)
if err != nil {
@@ -158,5 +168,15 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
clonedConfig.SecretKey = "****"
clonedConfig.AmapApplicationSecret = "****"
if clonedConfig.WebDAVConfig != nil {
clonedConfig.WebDAVConfig.Password = "****"
}
if clonedConfig.ReceiptImageRecognitionLLMConfig != nil {
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey = "****"
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
}
return clonedConfig
}
+2 -2
View File
@@ -3,7 +3,7 @@ package cmd
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/utils"
@@ -13,7 +13,7 @@ import (
var SecurityUtils = &cli.Command{
Name: "security",
Usage: "ezBookkeeping security utilities",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "gen-secret-key",
Usage: "Generate a random secret key",
+65 -5
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"os"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
clis "github.com/mayswind/ezbookkeeping/pkg/cli"
"github.com/mayswind/ezbookkeeping/pkg/core"
@@ -18,7 +18,7 @@ import (
var UserData = &cli.Command{
Name: "userdata",
Usage: "ezBookkeeping user data maintenance",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "user-add",
Usage: "Add new user",
@@ -260,6 +260,25 @@ var UserData = &cli.Command{
Required: true,
Usage: "Specific user name",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Required: false,
Usage: "Specific token type, supports \"normal\" and \"mcp\", default is \"normal\"",
},
},
},
{
Name: "user-session-revoke",
Usage: "Revoke the specified user session",
Action: bindAction(revokeUserToken),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Required: false,
Usage: "Specific token content",
},
},
},
{
@@ -702,7 +721,18 @@ func createNewUserToken(c *core.CliContext) error {
}
username := c.String("username")
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username)
tokenType := c.String("type")
if tokenType == "" {
tokenType = "normal"
}
if tokenType != "normal" && tokenType != "mcp" {
log.CliErrorf(c, "[user_data.createNewUserToken] token type is invalid")
return nil
}
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username, tokenType)
if err != nil {
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
@@ -715,6 +745,26 @@ func createNewUserToken(c *core.CliContext) error {
return nil
}
func revokeUserToken(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
token := c.String("token")
err = clis.UserData.RevokeUserToken(c, token)
if err != nil {
log.CliErrorf(c, "[user_data.revokeUserToken] error occurs when revoking user token")
return err
}
log.CliInfof(c, "[user_data.revokeUserToken] the specified user token has been revoked successfully")
return nil
}
func clearUserTokens(c *core.CliContext) error {
_, err := initializeSystem(c)
@@ -792,7 +842,11 @@ func exportUserTransaction(c *core.CliContext) error {
filePath := c.String("file")
fileType := c.String("type")
if fileType != "" && fileType != "csv" && fileType != "tsv" {
if fileType == "" {
fileType = "csv"
}
if fileType != "csv" && fileType != "tsv" {
log.CliErrorf(c, "[user_data.exportUserTransaction] export file type is not supported")
return errs.ErrNotSupported
}
@@ -891,14 +945,20 @@ func printUserInfo(user *models.User) {
fmt.Printf("[Language] %s\n", user.Language)
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek)
fmt.Printf("[FiscalYearStart] %s (%d)\n", user.FiscalYearStart, user.FiscalYearStart)
fmt.Printf("[CalendarDisplayType] %s (%d)\n", user.CalendarDisplayType, user.CalendarDisplayType)
fmt.Printf("[DateDisplayType] %s (%d)\n", user.DateDisplayType, user.DateDisplayType)
fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat)
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
fmt.Printf("[FiscalYearFormat] %s (%d)\n", user.FiscalYearFormat, user.FiscalYearFormat)
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
fmt.Printf("[NumeralSystem] %s (%d)\n", user.NumeralSystem, user.NumeralSystem)
fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping)
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
fmt.Printf("[CoordinateDisplayType] %s (%d)\n", user.CoordinateDisplayType, user.CoordinateDisplayType)
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
fmt.Printf("[FeatureRestriction] %s (%d)\n", user.FeatureRestriction, user.FeatureRestriction)
+4 -4
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"net"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -18,7 +18,7 @@ import (
var Utilities = &cli.Command{
Name: "utility",
Usage: "ezBookkeeping utilities",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "parse-default-request-id",
Usage: "Parse a request id which is generated by default request generator and show the details",
@@ -81,13 +81,13 @@ func sendTestMail(c *core.CliContext) error {
return err
}
if !config.EnableSMTP || mail.Container.Current == nil {
if !config.EnableSMTP {
return errs.ErrSMTPServerNotEnabled
}
toAddress := c.String("to")
err = mail.Container.Current.SendMail(&mail.MailMessage{
err = mail.Container.SendMail(&mail.MailMessage{
To: toAddress,
Subject: "ezBookkeeping test e-mail",
Body: "This is a test e-mail",
+118 -7
View File
@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"net/http"
"path/filepath"
"time"
@@ -11,13 +12,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"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/mcp"
"github.com/mayswind/ezbookkeeping/pkg/middlewares"
"github.com/mayswind/ezbookkeeping/pkg/requestid"
"github.com/mayswind/ezbookkeeping/pkg/settings"
@@ -29,7 +31,7 @@ import (
var WebServer = &cli.Command{
Name: "server",
Usage: "ezBookkeeping web server operation",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "run",
Usage: "Run ezBookkeeping web server",
@@ -63,6 +65,13 @@ func startWebServer(c *core.CliContext) error {
return err
}
err = mcp.InitializeMCPHandlers(config)
if err != nil {
log.BootErrorf(c, "[webserver.startWebServer] initializes mcp handlers failed, because %s", err.Error())
return err
}
err = cron.InitializeCronJobSchedulerContainer(c, config, true)
if err != nil {
@@ -70,7 +79,7 @@ func startWebServer(c *core.CliContext) error {
return err
}
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.Current.GetCurrentServerUniqId(), requestid.Container.Current.GetCurrentInstanceUniqId())
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.GetCurrentServerUniqId(), requestid.Container.GetCurrentInstanceUniqId())
uuidServerInfo := ""
if config.UuidGeneratorType == settings.InternalUuidGeneratorType {
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
@@ -98,6 +107,7 @@ func startWebServer(c *core.CliContext) error {
_ = v.RegisterValidation("validCurrency", validators.ValidCurrency)
_ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor)
_ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter)
_ = v.RegisterValidation("validFiscalYearStart", validators.ValidateFiscalYearStart)
}
router.NoRoute(bindApi(api.Default.ApiNotFound))
@@ -116,8 +126,13 @@ func startWebServer(c *core.CliContext) error {
router.StaticFile("favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
router.StaticFile("touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
router.StaticFile("manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
router.StaticFile("sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
router.GET("/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
for i := 0; i < len(workboxFileNames); i++ {
router.StaticFile("/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
}
router.StaticFile("/mobile", filepath.Join(config.StaticRootPath, "mobile.html"))
router.Static("/mobile/js", filepath.Join(config.StaticRootPath, "js"))
router.Static("/mobile/css", filepath.Join(config.StaticRootPath, "css"))
@@ -206,6 +221,27 @@ func startWebServer(c *core.CliContext) error {
qrCodeRoute.GET("/mobile_url.png", bindCachedImage(api.QrCodes.MobileUrlQrCodeHandler, qrCodeCacheStore))
}
if config.EnableMCPServer {
mcpRoute := router.Group("/mcp")
mcpRoute.Use(bindMiddleware(middlewares.RequestId(config)))
mcpRoute.Use(bindMiddleware(middlewares.RequestLog))
mcpRoute.Use(bindMiddleware(middlewares.MCPServerIpLimit(config)))
mcpRoute.Use(bindMiddleware(middlewares.JWTMCPAuthorization))
{
mcpRoute.POST("", bindJSONRPCApi(map[string]core.JSONRPCApiHandlerFunc{
"initialize": api.ModelContextProtocols.InitializeHandler,
"resources/list": api.ModelContextProtocols.ListResourcesHandler,
"resources/read": api.ModelContextProtocols.ReadResourceHandler,
"tools/list": api.ModelContextProtocols.ListToolsHandler,
"tools/call": api.ModelContextProtocols.CallToolHandler,
"ping": api.ModelContextProtocols.PingHandler,
}, map[string]int{
"notifications/initialized": http.StatusAccepted,
}))
mcpRoute.GET("", bindApi(api.Default.MethodNotAllowed))
}
}
apiRoute := router.Group("/api")
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
@@ -253,6 +289,7 @@ func startWebServer(c *core.CliContext) error {
{
// Tokens
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
apiV1Route.POST("/tokens/generate/mcp.json", bindApi(api.Tokens.TokenGenerateMCPHandler))
apiV1Route.POST("/tokens/revoke.json", bindApi(api.Tokens.TokenRevokeHandler))
apiV1Route.POST("/tokens/revoke_all.json", bindApi(api.Tokens.TokenRevokeAllHandler))
apiV1Route.POST("/tokens/refresh.json", bindApiWithTokenUpdate(api.Tokens.TokenRefreshHandler, config))
@@ -270,6 +307,11 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
}
// Application Cloud Settings
apiV1Route.GET("/users/settings/cloud/get.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsGetHandler))
apiV1Route.POST("/users/settings/cloud/update.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsUpdateHandler))
apiV1Route.POST("/users/settings/cloud/disable.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsDisableHandler))
// Two-Factor Authorization
if config.EnableTwoFactor {
apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler))
@@ -281,7 +323,9 @@ func startWebServer(c *core.CliContext) error {
// Data
apiV1Route.GET("/data/statistics.json", bindApi(api.DataManagements.DataStatisticsHandler))
apiV1Route.POST("/data/clear.json", bindApi(api.DataManagements.ClearDataHandler))
apiV1Route.POST("/data/clear/all.json", bindApi(api.DataManagements.ClearAllDataHandler))
apiV1Route.POST("/data/clear/transactions.json", bindApi(api.DataManagements.ClearAllTransactionsHandler))
apiV1Route.POST("/data/clear/transactions/by_account.json", bindApi(api.DataManagements.ClearAllTransactionsByAccountHandler))
if config.EnableDataExport {
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
@@ -296,11 +340,13 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/accounts/hide.json", bindApi(api.Accounts.AccountHideHandler))
apiV1Route.POST("/accounts/move.json", bindApi(api.Accounts.AccountMoveHandler))
apiV1Route.POST("/accounts/delete.json", bindApi(api.Accounts.AccountDeleteHandler))
apiV1Route.POST("/accounts/sub_account/delete.json", bindApi(api.Accounts.SubAccountDeleteHandler))
// Transactions
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
apiV1Route.GET("/transactions/reconciliation_statements.json", bindApi(api.Transactions.TransactionReconciliationStatementHandler))
apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler))
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
@@ -310,8 +356,10 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
if config.EnableDataImport {
apiV1Route.POST("/transactions/parse_dsv_file.json", bindApi(api.Transactions.TransactionParseImportDsvFileDataHandler))
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
apiV1Route.GET("/transactions/import/process.json", bindApi(api.Transactions.TransactionImportProcessHandler))
}
// Transaction Pictures
@@ -334,6 +382,7 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.GET("/transaction/tags/list.json", bindApi(api.TransactionTags.TagListHandler))
apiV1Route.GET("/transaction/tags/get.json", bindApi(api.TransactionTags.TagGetHandler))
apiV1Route.POST("/transaction/tags/add.json", bindApi(api.TransactionTags.TagCreateHandler))
apiV1Route.POST("/transaction/tags/add_batch.json", bindApi(api.TransactionTags.TagCreateBatchHandler))
apiV1Route.POST("/transaction/tags/modify.json", bindApi(api.TransactionTags.TagModifyHandler))
apiV1Route.POST("/transaction/tags/hide.json", bindApi(api.TransactionTags.TagHideHandler))
apiV1Route.POST("/transaction/tags/move.json", bindApi(api.TransactionTags.TagMoveHandler))
@@ -348,8 +397,20 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
// Large Language Models
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
if config.TransactionFromAIImageRecognition {
apiV1Route.POST("/llm/transactions/recognize_receipt_image.json", bindApi(api.LargeLanguageModels.RecognizeReceiptImageHandler))
}
}
// Exchange Rates
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
apiV1Route.POST("/exchange_rates/user_custom/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler))
apiV1Route.POST("/exchange_rates/user_custom/delete.json", bindApi(api.ExchangeRates.UserCustomExchangeRateDeleteHandler))
// System
apiV1Route.GET("/systems/version.json", bindApi(api.Systems.VersionHandler))
}
}
@@ -412,6 +473,56 @@ func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin
}
}
func bindJSONRPCApi(fns map[string]core.JSONRPCApiHandlerFunc, skipMethods map[string]int) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
var jsonRPCRequest core.JSONRPCRequest
reqErr := c.ShouldBindBodyWithJSON(&jsonRPCRequest)
if reqErr != nil {
utils.PrintJSONRPCErrorResult(c, nil, errs.NewIncompleteOrIncorrectSubmissionError(reqErr))
return
}
if skipMethods != nil {
httpStatusCode, exists := skipMethods[jsonRPCRequest.Method]
if exists {
c.AbortWithStatus(httpStatusCode)
return
}
}
fn, exists := fns[jsonRPCRequest.Method]
if !exists {
utils.PrintJSONRPCErrorResult(c, &jsonRPCRequest, errs.ErrApiNotFound)
return
}
result, err := fn(c, &jsonRPCRequest)
if err != nil {
utils.PrintJSONRPCErrorResult(c, &jsonRPCRequest, err)
} else {
utils.PrintJSONRPCSuccessResult(c, &jsonRPCRequest, result)
}
}
}
func bindEventStreamApi(fn core.EventStreamApiHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
utils.SetEventStreamHeader(c)
err := fn(c)
if err != nil {
utils.WriteEventStreamJsonErrorResult(c, err)
}
}
}
func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
@@ -420,7 +531,7 @@ func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.Han
if err != nil {
utils.PrintDataErrorResult(c, "text/javascript", err)
} else {
utils.PrintDataSuccessResult(c, "text/javascript", "", result)
utils.PrintDataSuccessResult(c, "text/javascript; charset=utf-8", "", result)
}
})
}
@@ -433,7 +544,7 @@ func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
utils.PrintDataSuccessResult(c, "text/csv", fileName, result)
utils.PrintDataSuccessResult(c, "text/csv; charset=utf-8", fileName, result)
}
}
}
@@ -446,7 +557,7 @@ func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
utils.PrintDataSuccessResult(c, "text/tab-separated-values", fileName, result)
utils.PrintDataSuccessResult(c, "text/tab-separated-values; charset=utf-8", fileName, result)
}
}
}
+109 -15
View File
@@ -37,6 +37,16 @@ enable_gzip = false
# Set to true to log each request and execution time
log_request = true
# Add X-Request-Id header to response to track user request or error, default is true
request_id_header = true
[mcp]
# Set to true to enable MCP (Model Context Protocol) server (via http / https web server) for AI/LLM access
enable_mcp = false
# MCP server allowed remote IPs, a comma-separated list of allowed remote IPs (asterisk * for any addresses, e.g. 192.168.1.* means any IPs in the 192.168.1.x subnet), leave blank to allow all remote IPs
mcp_allowed_remote_ips =
[database]
# Either "mysql", "postgres" or "sqlite3"
type = sqlite3
@@ -108,7 +118,7 @@ log_file_max_size = 104857600
log_file_max_days = 7
[storage]
# Object storage type, supports "local_filesystem" and "minio" currently
# Object storage type, supports "local_filesystem", "minio" and "webdav" currently
type = local_filesystem
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
@@ -132,6 +142,82 @@ minio_bucket = ezbookkeeping
# For "minio" storage only, the root path to store files in minio
minio_root_path = /
# For "webdav" storage only, the webdav url
webdav_url =
# For "webdav" storage only, the webdav username
webdav_username =
# For "webdav" storage only, the webdav password
webdav_password =
# For "webdav" storage only, the webdav root path to store files
webdav_root_path = /
# For "webdav" storage only, requesting webdav url timeout (0 - 4294967295 milliseconds)
# Set to 0 to disable timeout for requesting webdav url, default is 10000 (10 seconds)
webdav_request_timeout = 10000
# For "webdav" storage only, proxy for requesting webdav url, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
webdav_proxy = system
# For "webdav" storage only, set to true to skip tls verification when connect webdav
webdav_skip_tls_verify = false
[llm]
# Set to true to enable creating transactions from AI image recognition results, requires "llm_provider" and its related model id to be configured properly in "llm_image_recognition" section
transaction_from_ai_image_recognition = false
# Maximum allowed AI recognition picture file size (1 - 4294967295 bytes)
max_ai_recognition_picture_size = 10485760
[llm_image_recognition]
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "openrouter", "ollama", "google_ai"
llm_provider =
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
openai_api_key =
# For "openai" llm provider only, receipt image recognition model for creating transactions from images
openai_model_id =
# For "openai_compatible" llm provider only, OpenAI compatible API base url, e.g. "https://api.openai.com/v1/"
openai_compatible_base_url =
# For "openai_compatible" llm provider only, OpenAI compatible API secret key
openai_compatible_api_key =
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
openai_compatible_model_id =
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
openrouter_api_key =
# For "openrouter" llm provider only, receipt image recognition model for creating transactions from images
openrouter_model_id =
# For "ollama" llm provider only, Ollama server url, e.g. "http://127.0.0.1:11434/"
ollama_server_url =
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
ollama_model_id =
# For "google_ai" llm provider only, Google AI Studio API key, please visit https://aistudio.google.com/apikey for more information
google_ai_api_key =
# For "google_ai" llm provider only, receipt image recognition model for creating transactions from images
google_ai_model_id =
# Requesting large language model api timeout (0 - 4294967295 milliseconds)
# Set to 0 to disable timeout for requesting large language model api, default is 60000 (60 seconds)
request_timeout = 60000
# Proxy for ezbookkeeping server requesting large language model api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
proxy = system
# Set to true to skip tls verification when request large language model api
skip_tls_verify = false
[uuid]
# Uuid generator type, supports "internal" currently
generator_type = internal
@@ -161,9 +247,6 @@ enable_create_scheduled_transaction = true
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
secret_key =
# Set to true to enable two-factor authorization
enable_two_factor = true
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
token_expired_time = 2592000
@@ -180,8 +263,21 @@ email_verify_token_expired_time = 3600
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
password_reset_token_expired_time = 3600
# Add X-Request-Id header to response to track user request or error, default is true
request_id_header = true
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
max_failures_per_ip_per_minute = 5
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
max_failures_per_user_per_minute = 5
[auth]
# Set to true to enable two-factor authorization
enable_two_factor = true
# Set to true to allow users to reset password
enable_forget_password = true
# Set to true to require email must be verified when use forget password
forget_password_require_email_verify = false
[user]
# Set to true to allow users to register account by themselves
@@ -193,12 +289,6 @@ enable_email_verify = false
# Set to true to require email must be verified when login
enable_force_email_verify = false
# Set to true to allow users to reset password
enable_forget_password = true
# Set to true to require email must be verified when use forget password
forget_password_require_email_verify = false
# Set to true to allow users to upload transaction pictures
enable_transaction_picture = true
@@ -230,6 +320,8 @@ max_user_avatar_size = 1048576
# 9: Import Transactions
# 10: Export Transactions
# 11: Clear All Data
# 12: Sync Application Settings
# 13: MCP (Model Context Protocol) Access
default_feature_restrictions =
[data]
@@ -248,7 +340,7 @@ enable_tips_in_login_page = false
# The custom tips displayed in login page, it supports multi-language configuration
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
# For example, login_page_tips_content_zh_hans means the notification content in Simplified Chinese
# For example, login_page_tips_content_zh_hans means the notification content in Chinese (Simplified)
login_page_tips_content =
[notification]
@@ -257,7 +349,7 @@ enable_notification_after_register = false
# The notification content displayed each time users register, it supports multi-language configuration
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
# For example, after_login_notification_content_zh_hans means the notification content in Simplified Chinese
# For example, after_login_notification_content_zh_hans means the notification content in Chinese (Simplified)
after_register_notification_content =
# Set to true to display custom notification in home page every time users login
@@ -315,7 +407,7 @@ amap_application_key =
# "external_proxy": use an external proxy to request amap api (amap application secret should be set by external proxy)
# "plain_text": append amap application secret to frontend request directly (insecurity for public network)
# Please visit https://developer.amap.com/api/jsapi-v2/guide/abc/load for more information
amap_security_verification_method = plain_text
amap_security_verification_method = internal_proxy
# For "amap" map provider only, Amap JavaScript API application secret, this setting must be provided when "amap_security_verification_method" is set to "internal_proxy" or "plain_text", please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
amap_application_secret =
@@ -354,8 +446,10 @@ custom_map_tile_server_default_zoom_level = 14
# "national_bank_of_romania": https://www.bnr.ro/Exchange-rates-1224.aspx
# "bank_of_russia": https://www.cbr.ru/eng/currency_base/daily/
# "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates
# "national_bank_of_ukraine": https://bank.gov.ua/ua/markets/exchangerates
# "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/
# "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx
# "user_custom": users set their own exchange rates data in the UI
data_source = euro_central_bank
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds)
+27 -32
View File
@@ -1,36 +1,31 @@
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import vueTsEslintConfig from '@vue/eslint-config-typescript';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
import { includeIgnoreFile } from '@eslint/compat';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const gitignorePath = path.resolve(__dirname, '.gitignore');
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default [...compat.extends('eslint:recommended', 'plugin:vue/vue3-essential'),
includeIgnoreFile(gitignorePath), {
languageOptions: {
globals: {
...globals.node,
export default [
...pluginVue.configs['flat/essential'],
...vueTsEslintConfig(),
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
}
},
},
files: [
"**/*.{vue,js,jsx,cjs,mjs}"
],
rules: {
'vue/no-use-v-if-with-v-for': 'off',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
{
ignores: [
'dist/**',
'**/*.{js,jsx,cjs,mjs}'
]
},
}];
{
files: [
'**/*.{vue,ts,tsx,mts,js,jsx,cjs,mjs}'
],
rules: {
'vue/valid-v-slot': ['error', {
allowModifiers: true
}]
}
},
];
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=ezBookkeeping, a lightweight personal bookkeeping app hosted by yourself.
Description=ezBookkeeping, a lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features.
After=syslog.target
After=network.target
After=mariadb.service mysqld.service postgresql.service
+6 -4
View File
@@ -1,12 +1,13 @@
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/cmd"
"github.com/mayswind/ezbookkeeping/pkg/settings"
@@ -27,10 +28,11 @@ var (
func main() {
settings.Version = Version
settings.CommitHash = CommitHash
settings.BuildTime = BuildUnixTime
app := &cli.App{
cmd := &cli.Command{
Name: "ezBookkeeping",
Usage: "A lightweight personal bookkeeping app hosted by yourself.",
Usage: "A lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.",
Version: GetFullVersion(),
Commands: []*cli.Command{
cmd.WebServer,
@@ -52,7 +54,7 @@ func main() {
},
}
err := app.Run(os.Args)
err := cmd.Run(context.Background(), os.Args)
if err != nil {
log.Fatalf("Failed to run ezBookkeeping with %s: %v", os.Args, err)
+47 -35
View File
@@ -1,86 +1,98 @@
module github.com/mayswind/ezbookkeeping
go 1.22
go 1.25
require (
github.com/boombuler/barcode v1.0.2
github.com/boombuler/barcode v1.1.0
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.12.3
github.com/go-playground/validator/v10 v10.22.1
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gin-contrib/cache v1.4.1
github.com/gin-contrib/gzip v1.2.3
github.com/gin-gonic/gin v1.10.1
github.com/go-co-op/gocron/v2 v2.16.5
github.com/go-playground/validator/v10 v10.27.0
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/invopop/jsonschema v0.13.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.24
github.com/minio/minio-go/v7 v7.0.80
github.com/mattn/go-sqlite3 v1.14.32
github.com/minio/minio-go/v7 v7.0.95
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.4.0
github.com/pquerna/otp v1.5.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.4
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v3 v3.4.1
github.com/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
golang.org/x/text v0.19.0
github.com/xuri/excelize/v2 v2.9.0
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
golang.org/x/text v0.28.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/mail.v2 v2.3.1
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.9
xorm.io/xorm v1.3.10
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/redigo v1.8.9 // indirect
github.com/gomodule/redigo v1.9.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/memcachier/mc/v3 v3.0.3 // indirect
github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/tealeg/xlsx v1.0.5 // indirect
github.com/tiendc/go-deepcopy v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.2.14 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/arch v0.8.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.1 // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/sys v0.26.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+91 -74
View File
@@ -4,29 +4,30 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0p
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -39,53 +40,55 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cache v1.3.0 h1:wEEw38uvb4rTraQJVpd9ex4ZotXNlc0fSaSUsuPXS/w=
github.com/gin-contrib/cache v1.3.0/go.mod h1:EA63LrWGI5vwSI95TS5fgBrtxZ1tM2NKx+NrEeyEDcU=
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
github.com/gin-contrib/gzip v1.0.1/go.mod h1:njt428fdUNRvjuJf16tZMYZ2Yl+WQB53X5wmhDwXvC4=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/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.12.3 h1:3JkKjkFoAPp/i0YE+sonlF5gi+xnBChwYh75nX16MaE=
github.com/go-co-op/gocron/v2 v2.12.3/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.16.5 h1:j228Jxk7bb9CF8LKR3gS+bK3rcjRUINjlVI+ZMp26Ss=
github.com/go-co-op/gocron/v2 v2.16.5/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -98,88 +101,102 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/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.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
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.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw=
github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
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=
@@ -198,5 +215,5 @@ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYm
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU=
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
xorm.io/xorm v1.3.10 h1:yR83hTT4mKIPyA/lvWFTzS35xjLwkiYnwdw0Qupeh0o=
xorm.io/xorm v1.3.10/go.mod h1:Lo7hmsFF0F0GbDE7ubX5ZKa+eCf0eCuiJAUG3oI5cxQ=
+21
View File
@@ -0,0 +1,21 @@
import { type JestConfigWithTsJest, createDefaultEsmPreset } from 'ts-jest';
const presetConfig = createDefaultEsmPreset({
tsconfig: '<rootDir>/tsconfig.jest.json'
});
const config: JestConfigWithTsJest = {
...presetConfig,
clearMocks: true,
collectCoverage: false,
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1"
},
testEnvironment: "node",
testMatch: [
"**/__tests__/**/*.[jt]s?(x)",
"!**/__tests__/*_gen.[jt]s?(x)"
]
};
export default config;
+7574 -2055
View File
File diff suppressed because it is too large Load Diff
+45 -25
View File
@@ -1,6 +1,6 @@
{
"name": "ezbookkeeping",
"version": "0.7.0",
"version": "1.1.0",
"private": true,
"repository": {
"type": "git",
@@ -15,12 +15,13 @@
"serve": "cross-env NODE_ENV=development vite",
"build": "cross-env NODE_ENV=production vite build",
"serve:dist": "vite preview",
"lint": "eslint . --fix"
"lint": "vue-tsc --noEmit && eslint . --fix",
"test": "cross-env TS_NODE_PROJECT=\"./tsconfig.jest.json\" jest"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@vuepic/vue-datepicker": "^10.0.0",
"axios": "^1.7.7",
"@vuepic/vue-datepicker": "^11.0.2",
"axios": "^1.11.0",
"cbor-js": "^0.1.0",
"clipboard": "^2.0.11",
"crypto-js": "^4.2.0",
@@ -29,42 +30,61 @@
"framework7": "^8.3.4",
"framework7-icons": "^5.0.5",
"framework7-vue": "^8.3.4",
"jalaali-js": "^1.2.8",
"leaflet": "^1.9.4",
"line-awesome": "^1.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"pinia": "^2.2.5",
"moment-timezone": "^0.6.0",
"pinia": "^3.0.3",
"register-service-worker": "^1.7.2",
"skeleton-elements": "^4.0.1",
"swiper": "^10.2.0",
"ua-parser-js": "^1.0.39",
"vue": "^3.5.12",
"vue": "^3.5.21",
"vue-echarts": "^7.0.3",
"vue-i18n": "^10.0.4",
"vue-router": "^4.4.5",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.7.3"
"vuetify": "^3.9.7"
},
"devDependencies": {
"@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@vitejs/plugin-vue": "^5.1.4",
"cross-env": "^7.0.3",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"@jest/globals": "^30.1.2",
"@tsconfig/node24": "^24.0.1",
"@types/cbor-js": "^0.1.1",
"@types/crypto-js": "^4.2.2",
"@types/git-rev-sync": "^2.0.2",
"@types/jalaali-js": "^1.2.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.3.1",
"@types/ua-parser-js": "^0.7.39",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"cross-env": "^10.0.0",
"eslint": "^9.35.0",
"eslint-plugin-vue": "^10.4.0",
"git-rev-sync": "^3.0.2",
"globals": "^15.11.0",
"postcss-preset-env": "^10.0.9",
"sass": "^1.80.6",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.5",
"vite-plugin-vuetify": "^2.0.4"
"jest": "^30.1.3",
"postcss-preset-env": "^10.3.1",
"sass": "^1.92.1",
"ts-jest": "^29.4.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"vite": "^7.1.4",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-pwa": "^1.0.3",
"vite-plugin-vuetify": "^2.1.2",
"vue-tsc": "^3.0.6"
},
"browserslist": [
"> 1%",
"last 2 versions",
"last 5 Chrome versions",
"last 5 Firefox versions",
"last 5 Safari versions",
"last 5 Edge versions",
"last 5 ChromeAndroid versions",
"last 5 iOS versions",
"not IE <= 11",
"not dead"
]
}
+255 -20
View File
@@ -28,6 +28,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
accounts: services.Accounts,
@@ -284,7 +287,7 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
log.Infof(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
accountInfoResp := mainAccount.ToAccountInfoResponse()
if len(childrenAccounts) > 0 {
@@ -308,11 +311,27 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if accountModifyReq.Id <= 0 {
return nil, errs.ErrAccountIdInvalid
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[accounts.AccountModifyHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
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
}
if accountModifyReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountModifyReq.CreditCardStatementDate != 0 {
log.Warnf(c, "[accounts.AccountModifyHandler] cannot set statement date with category \"%d\"", accountModifyReq.Category)
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
}
uid := c.GetCurrentUid()
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
@@ -328,20 +347,81 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
return nil, errs.ErrAccountNotFound
}
if len(accountModifyReq.SubAccounts)+1 != len(accountAndSubAccounts) {
return nil, errs.ErrCannotAddOrDeleteSubAccountsWhenModify
if accountModifyReq.Currency != nil && mainAccount.Currency != *accountModifyReq.Currency {
return nil, errs.ErrNotSupportedChangeCurrency
}
if accountModifyReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountModifyReq.CreditCardStatementDate != 0 {
log.Warnf(c, "[accounts.AccountModifyHandler] cannot set statement date with category \"%d\"", accountModifyReq.Category)
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
if accountModifyReq.Balance != nil {
return nil, errs.ErrNotSupportedChangeBalance
}
if mainAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
if accountModifyReq.BalanceTime != nil {
return nil, errs.ErrNotSupportedChangeBalanceTime
}
if mainAccount.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
if len(accountModifyReq.SubAccounts) > 0 {
log.Warnf(c, "[accounts.AccountModifyHandler] account cannot have any sub-accounts")
return nil, errs.ErrAccountCannotHaveSubAccounts
}
} else if mainAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
if len(accountModifyReq.SubAccounts) < 1 {
log.Warnf(c, "[accounts.AccountModifyHandler] account does not have any sub-accounts")
return nil, errs.ErrAccountHaveNoSubAccount
}
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
subAccount := accountModifyReq.SubAccounts[i]
subAccountReq := accountModifyReq.SubAccounts[i]
if subAccount.CreditCardStatementDate != 0 {
if subAccountReq.Category != accountModifyReq.Category {
log.Warnf(c, "[accounts.AccountModifyHandler] category of sub-account#%d not equals to parent", i)
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
}
if subAccountReq.Id == 0 { // create new sub-account
if subAccountReq.Currency == nil {
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d not set currency", i)
return nil, errs.ErrAccountCurrencyInvalid
} else if subAccountReq.Currency != nil && *subAccountReq.Currency == validators.ParentAccountCurrencyPlaceholder {
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set currency placeholder", i)
return nil, errs.ErrAccountCurrencyInvalid
}
if subAccountReq.Balance == nil {
defaultBalance := int64(0)
subAccountReq.Balance = &defaultBalance
}
if *subAccountReq.Balance == 0 {
defaultBalanceTime := int64(0)
subAccountReq.BalanceTime = &defaultBalanceTime
}
if *subAccountReq.Balance != 0 && (subAccountReq.BalanceTime == nil || *subAccountReq.BalanceTime <= 0) {
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d balance time is not set", i)
return nil, errs.ErrAccountBalanceTimeNotSet
}
} else { // modify existed sub-account
subAccount, exists := accountMap[subAccountReq.Id]
if !exists {
return nil, errs.ErrAccountNotFound
}
if subAccountReq.Currency != nil && subAccount.Currency != *subAccountReq.Currency {
return nil, errs.ErrNotSupportedChangeCurrency
}
if subAccountReq.Balance != nil {
return nil, errs.ErrNotSupportedChangeBalance
}
if subAccountReq.BalanceTime != nil {
return nil, errs.ErrNotSupportedChangeBalanceTime
}
}
if subAccountReq.CreditCardStatementDate != 0 {
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set statement date", i)
return nil, errs.ErrCannotSetStatementDateForSubAccount
}
@@ -350,6 +430,9 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
anythingUpdate := false
var toUpdateAccounts []*models.Account
var toAddAccounts []*models.Account
var toAddAccountBalanceTimes []int64
var toDeleteAccountIds []int64
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
@@ -358,18 +441,43 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
toUpdateAccounts = append(toUpdateAccounts, toUpdateAccount)
}
toDeleteAccountIds = a.getToDeleteSubAccountIds(&accountModifyReq, mainAccount, accountAndSubAccounts)
if len(toDeleteAccountIds) > 0 {
anythingUpdate = true
}
maxOrderId := int32(0)
for i := 0; i < len(accountAndSubAccounts); i++ {
account := accountAndSubAccounts[i]
if account.AccountId != mainAccount.AccountId && account.DisplayOrder > maxOrderId {
maxOrderId = account.DisplayOrder
}
}
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
subAccountReq := accountModifyReq.SubAccounts[i]
if _, exists := accountMap[subAccountReq.Id]; !exists {
return nil, errs.ErrAccountNotFound
}
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
if toUpdateSubAccount != nil {
anythingUpdate = true
toUpdateAccounts = append(toUpdateAccounts, toUpdateSubAccount)
maxOrderId = maxOrderId + 1
newSubAccount := a.createNewSubAccountModelForModify(uid, mainAccount.Type, subAccountReq, maxOrderId)
toAddAccounts = append(toAddAccounts, newSubAccount)
if subAccountReq.BalanceTime != nil {
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, *subAccountReq.BalanceTime)
} else {
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, 0)
}
} else {
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
if toUpdateSubAccount != nil {
anythingUpdate = true
toUpdateAccounts = append(toUpdateAccounts, toUpdateSubAccount)
}
}
}
@@ -377,7 +485,43 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
return nil, errs.ErrNothingWillBeUpdated
}
err = a.accounts.ModifyAccounts(c, uid, toUpdateAccounts)
if len(toAddAccounts) > 0 && a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountModifyReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_SUBACCOUNT, uid, accountModifyReq.ClientSessionId)
if found {
log.Infof(c, "[accounts.AccountModifyHandler] another account \"id:%s\" modification has been created for user \"uid:%d\"", remark, uid)
accountId, err := utils.StringToInt64(remark)
if err == nil {
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId)
if err != nil {
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
mainAccount, exists := accountMap[accountId]
if !exists {
return nil, errs.ErrOperationFailed
}
accountInfoResp := mainAccount.ToAccountInfoResponse()
for i := 0; i < len(accountAndSubAccounts); i++ {
if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId {
subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse()
accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp)
}
}
return accountInfoResp, nil
}
}
}
err = a.accounts.ModifyAccounts(c, mainAccount, toUpdateAccounts, toAddAccounts, toAddAccountBalanceTimes, toDeleteAccountIds, utcOffset)
if err != nil {
log.Errorf(c, "[accounts.AccountModifyHandler] failed to update account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
@@ -386,6 +530,10 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
log.Infof(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
if len(toAddAccounts) > 0 {
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_SUBACCOUNT, uid, accountModifyReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
}
accountRespMap := make(map[int64]*models.AccountInfoResponse)
for i := 0; i < len(toUpdateAccounts); i++ {
@@ -402,11 +550,23 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
accountRespMap[accountResp.Id] = accountResp
}
for i := 0; i < len(toAddAccounts); i++ {
account := toAddAccounts[i]
accountResp := account.ToAccountInfoResponse()
accountRespMap[accountResp.Id] = accountResp
}
deletedAccountIds := make(map[int64]bool)
for i := 0; i < len(toDeleteAccountIds); i++ {
deletedAccountIds[toDeleteAccountIds[i]] = true
}
for i := 0; i < len(accountAndSubAccounts); i++ {
oldAccount := accountAndSubAccounts[i]
_, exists := accountRespMap[oldAccount.AccountId]
if !exists {
if !exists && !deletedAccountIds[oldAccount.AccountId] {
oldAccountResp := oldAccount.ToAccountInfoResponse()
accountRespMap[oldAccountResp.Id] = oldAccountResp
}
@@ -415,8 +575,19 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
accountResp := accountRespMap[accountModifyReq.Id]
for i := 0; i < len(accountAndSubAccounts); i++ {
if accountAndSubAccounts[i].ParentAccountId == accountResp.Id {
subAccountResp := accountRespMap[accountAndSubAccounts[i].AccountId]
account := accountAndSubAccounts[i]
if account.ParentAccountId == accountResp.Id && !deletedAccountIds[account.AccountId] {
subAccountResp := accountRespMap[account.AccountId]
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
}
}
for i := 0; i < len(toAddAccounts); i++ {
account := toAddAccounts[i]
if account.ParentAccountId == accountResp.Id {
subAccountResp := accountRespMap[account.AccountId]
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
}
}
@@ -505,6 +676,28 @@ func (a *AccountsApi) AccountDeleteHandler(c *core.WebContext) (any, *errs.Error
return true, nil
}
// SubAccountDeleteHandler deletes an existed sub-account by request parameters for current user
func (a *AccountsApi) SubAccountDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var accountDeleteReq models.AccountDeleteRequest
err := c.ShouldBindJSON(&accountDeleteReq)
if err != nil {
log.Warnf(c, "[accounts.SubAccountDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.accounts.DeleteSubAccount(c, uid, accountDeleteReq.Id)
if err != nil {
log.Errorf(c, "[accounts.SubAccountDeleteHandler] failed to delete sub-account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[accounts.SubAccountDeleteHandler] user \"uid:%d\" has deleted sub-account \"id:%d\"", uid, accountDeleteReq.Id)
return true, nil
}
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, isSubAccount bool, order int32) *models.Account {
accountExtend := &models.AccountExtend{}
@@ -527,6 +720,24 @@ func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.
}
}
func (a *AccountsApi) createNewSubAccountModelForModify(uid int64, accountType models.AccountType, accountModifyReq *models.AccountModifyRequest, order int32) *models.Account {
accountExtend := &models.AccountExtend{}
return &models.Account{
Uid: uid,
Name: accountModifyReq.Name,
DisplayOrder: order,
Category: accountModifyReq.Category,
Type: accountType,
Icon: accountModifyReq.Icon,
Color: accountModifyReq.Color,
Currency: *accountModifyReq.Currency,
Balance: *accountModifyReq.Balance,
Comment: accountModifyReq.Comment,
Extend: accountExtend,
}
}
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) ([]*models.Account, []int64) {
if len(accountCreateReq.SubAccounts) <= 0 {
return nil, nil
@@ -584,3 +795,27 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
return nil
}
func (a *AccountsApi) getToDeleteSubAccountIds(accountModifyReq *models.AccountModifyRequest, mainAccount *models.Account, accountAndSubAccounts []*models.Account) []int64 {
newSubAccountIds := make(map[int64]bool, len(accountModifyReq.SubAccounts))
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
newSubAccountIds[accountModifyReq.SubAccounts[i].Id] = true
}
toDeleteAccountIds := make([]int64, 0)
for i := 0; i < len(accountAndSubAccounts); i++ {
subAccount := accountAndSubAccounts[i]
if subAccount.AccountId == mainAccount.AccountId {
continue
}
if _, exists := newSubAccountIds[subAccount.AccountId]; !exists {
toDeleteAccountIds = append(toDeleteAccountIds, subAccount.AccountId)
}
}
return toDeleteAccountIds
}
+94 -9
View File
@@ -5,6 +5,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -15,8 +16,10 @@ import (
// AuthorizationsApi represents authorization api
type AuthorizationsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
ApiWithUserInfo
users *services.UserService
userAppCloudSettings *services.UserApplicationCloudSettingsService
tokens *services.TokenService
twoFactorAuthorizations *services.TwoFactorAuthorizationService
}
@@ -27,6 +30,12 @@ var (
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
ApiWithUserInfo: ApiWithUserInfo{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
@@ -36,6 +45,7 @@ var (
},
},
users: services.Users,
userAppCloudSettings: services.UserApplicationCloudSettings,
tokens: services.Tokens,
twoFactorAuthorizations: services.TwoFactorAuthorizations,
}
@@ -51,7 +61,23 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
return nil, errs.ErrLoginNameOrPasswordInvalid
}
user, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
err = a.CheckFailureCount(c, 0)
if err != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
user, uid, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
if errs.IsCustomError(err) {
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
if failureCheckErr != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, failureCheckErr.Error())
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
}
}
if err != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
@@ -116,9 +142,18 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
c.SetTokenClaims(claims)
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
if err != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
}
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)
authResp := a.getAuthResponse(c, token, twoFactorEnable, user, applicationCloudSettingSlice)
return authResp, nil
}
@@ -133,6 +168,13 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
}
uid := c.GetCurrentUid()
err = a.CheckFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
if err != nil {
@@ -142,6 +184,14 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
err = a.CheckAndIncreaseFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
return nil, errs.ErrPasscodeInvalid
}
@@ -179,9 +229,18 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
c.SetTextualToken(token)
c.SetTokenClaims(claims)
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
}
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)
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
return authResp, nil
}
@@ -196,6 +255,13 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
}
uid := c.GetCurrentUid()
err = a.CheckFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
@@ -226,6 +292,15 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt)
if errs.IsCustomError(err) {
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
if failureCheckErr != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, failureCheckErr.Error())
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
}
}
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrTwoFactorRecoveryCodeNotExist)
@@ -248,17 +323,27 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
c.SetTextualToken(token)
c.SetTokenClaims(claims)
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
}
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)
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
return authResp, nil
}
func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User) *models.AuthResponse {
func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User, applicationCloudSettings *models.ApplicationCloudSettingSlice) *models.AuthResponse {
return &models.AuthResponse{
Token: token,
Need2FA: need2FA,
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()),
Token: token,
Need2FA: need2FA,
User: a.GetUserBasicInfo(user),
ApplicationCloudSettings: applicationCloudSettings,
NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()),
}
}
+88 -13
View File
@@ -5,9 +5,13 @@ import (
"sort"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const internalTransactionPictureUrlFormat = "%spictures/%d.%s"
@@ -19,7 +23,7 @@ type ApiUsingConfig struct {
// CurrentConfig returns the current config
func (a *ApiUsingConfig) CurrentConfig() *settings.Config {
return a.container.Current
return a.container.GetCurrentConfig()
}
// GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model
@@ -49,15 +53,15 @@ func (a *ApiUsingConfig) GetAfterRegisterNotificationContent(userLanguage string
language = clientLanguage
}
if !a.container.Current.AfterRegisterNotification.Enabled {
if !a.CurrentConfig().AfterRegisterNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterRegisterNotification.MultiLanguageContent[language]; exists {
if multiLanguageContent, exists := a.CurrentConfig().AfterRegisterNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterRegisterNotification.DefaultContent
return a.CurrentConfig().AfterRegisterNotification.DefaultContent
}
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
@@ -68,15 +72,15 @@ func (a *ApiUsingConfig) GetAfterLoginNotificationContent(userLanguage string, c
language = clientLanguage
}
if !a.container.Current.AfterLoginNotification.Enabled {
if !a.CurrentConfig().AfterLoginNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterLoginNotification.MultiLanguageContent[language]; exists {
if multiLanguageContent, exists := a.CurrentConfig().AfterLoginNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterLoginNotification.DefaultContent
return a.CurrentConfig().AfterLoginNotification.DefaultContent
}
// GetAfterOpenNotificationContent returns the notification content displayed each time users open the app
@@ -87,19 +91,20 @@ func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, cl
language = clientLanguage
}
if !a.container.Current.AfterOpenNotification.Enabled {
if !a.CurrentConfig().AfterOpenNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterOpenNotification.MultiLanguageContent[language]; exists {
if multiLanguageContent, exists := a.CurrentConfig().AfterOpenNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterOpenNotification.DefaultContent
return a.CurrentConfig().AfterOpenNotification.DefaultContent
}
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
type ApiUsingDuplicateChecker struct {
ApiUsingConfig
container *duplicatechecker.DuplicateCheckerContainer
}
@@ -108,9 +113,79 @@ func (a *ApiUsingDuplicateChecker) GetSubmissionRemark(checkerType duplicatechec
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)
// SetSubmissionRemarkIfEnable saves the identification and remark by the current duplicate checker if the duplicate submission check is enabled
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string) {
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
}
}
// RemoveSubmissionRemarkIfEnable removes the identification and remark by the current duplicate checker if the duplicate submission check is enabled
func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) {
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
a.container.RemoveSubmissionRemark(checkerType, uid, identification)
}
}
// CheckFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
func (a *ApiUsingDuplicateChecker) CheckFailureCount(c *core.WebContext, uid int64) error {
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
clientIp := c.ClientIP()
ipFailureCount := a.container.GetFailureCount(clientIp)
if ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
return errs.ErrFailureCountLimitReached
}
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
uidFailureCount := a.container.GetFailureCount(utils.Int64ToString(uid))
if uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
return errs.ErrFailureCountLimitReached
}
}
return nil
}
// CheckAndIncreaseFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
func (a *ApiUsingDuplicateChecker) CheckAndIncreaseFailureCount(c *core.WebContext, uid int64) error {
clientIp := c.ClientIP()
ipFailureCount := uint32(0)
uidFailureCount := uint32(0)
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
ipFailureCount = a.container.GetFailureCount(clientIp)
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
uidFailureCount = a.container.GetFailureCount(utils.Int64ToString(uid))
}
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount < a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", previous failure count: %d", clientIp, ipFailureCount)
a.container.IncreaseFailureCount(clientIp)
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount < a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", previous failure count: %d", uid, uidFailureCount)
a.container.IncreaseFailureCount(utils.Int64ToString(uid))
}
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
return errs.ErrFailureCountLimitReached
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
return errs.ErrFailureCountLimitReached
}
return nil
}
// ApiUsingAvatarProvider represents an api that need to use avatar provider
+177 -27
View File
@@ -15,19 +15,21 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const pageCountForClearTransactions = 1000
const pageCountForDataExport = 1000
// DataManagementsApi represents data management api
type DataManagementsApi struct {
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
tokens *services.TokenService
users *services.UserService
accounts *services.AccountService
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
pictures *services.TransactionPictureService
templates *services.TransactionTemplateService
userCustomExchangeRates *services.UserCustomExchangeRatesService
}
// Initialize a data management api singleton instance
@@ -36,14 +38,15 @@ var (
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,
tokens: services.Tokens,
users: services.Users,
accounts: services.Accounts,
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
pictures: services.TransactionPictures,
templates: services.TransactionTemplates,
userCustomExchangeRates: services.UserCustomExchangeRates,
}
)
@@ -122,13 +125,13 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
return dataStatisticsResp, nil
}
// ClearDataHandler deletes all user data
func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Error) {
// ClearAllDataHandler deletes all user data
func (a *DataManagementsApi) ClearAllDataHandler(c *core.WebContext) (any, *errs.Error) {
var clearDataReq models.ClearDataRequest
err := c.ShouldBindJSON(&clearDataReq)
if err != nil {
log.Warnf(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[data_managements.ClearAllDataHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -137,7 +140,7 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[data_managements.ClearAllDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
@@ -154,32 +157,134 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
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())
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction templates, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.transactions.DeleteAllTransactions(c, uid)
err = a.transactions.DeleteAllTransactions(c, uid, true)
if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearAllDataHandler] 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.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearAllDataHandler] 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.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tags, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[data_managements.ClearAllDataHandler] user \"uid:%d\" has cleared all data", uid)
return true, nil
}
// ClearAllTransactionsHandler deletes all transactions
func (a *DataManagementsApi) ClearAllTransactionsHandler(c *core.WebContext) (any, *errs.Error) {
var clearDataReq models.ClearDataRequest
err := c.ShouldBindJSON(&clearDataReq)
if err != nil {
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
err = a.transactions.DeleteAllTransactions(c, uid, false)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllTransactionsHandler] failed to delete all transactions, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[data_managements.ClearAllTransactionsHandler] user \"uid:%d\" has cleared all transactions", uid)
return true, nil
}
// ClearAllTransactionsByAccountHandler deletes all transactions of specified account
func (a *DataManagementsApi) ClearAllTransactionsByAccountHandler(c *core.WebContext) (any, *errs.Error) {
var clearDataReq models.ClearAccountTransactionsRequest
err := c.ShouldBindJSON(&clearDataReq)
if err != nil {
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
account, err := a.accounts.GetAccountByAccountId(c, uid, clearDataReq.AccountId)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", uid, clearDataReq.AccountId, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if account.Hidden {
return nil, errs.ErrCannotDeleteTransactionInHiddenAccount
}
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
return nil, errs.ErrCannotDeleteTransactionInParentAccount
}
err = a.transactions.DeleteAllTransactionsOfAccount(c, uid, account.AccountId, pageCountForClearTransactions)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to delete all transactions in account \"id:%d\", because %s", account.AccountId, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[data_managements.ClearAllTransactionsByAccountHandler] user \"uid:%d\" has cleared all transactions in account \"id:%d\"", uid, account.AccountId)
return true, nil
}
@@ -188,6 +293,14 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
return nil, "", errs.ErrDataExportNotAllowed
}
var exportTransactionDataReq models.ExportTransactionDataRequest
err := c.ShouldBindQuery(&exportTransactionDataReq)
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] parse request failed, because %s", err.Error())
return nil, "", errs.NewIncompleteOrIncorrectSubmissionError(err)
}
timezone := time.Local
utcOffset, err := c.GetClientTimezoneOffset()
@@ -244,7 +357,44 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
categoryMap := a.categories.GetCategoryMapByList(categories)
tagMap := a.tags.GetTagMapByList(tags)
allTransactions, err := a.transactions.GetAllTransactions(c, uid, pageCountForDataExport, true)
allAccountIds, err := a.accounts.GetAccountOrSubAccountIds(c, exportTransactionDataReq.AccountIds, uid)
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] get account error, because %s", err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
allCategoryIds, err := a.categories.GetCategoryOrSubCategoryIds(c, exportTransactionDataReq.CategoryIds, uid)
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] get transaction category error, because %s", err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
var allTagIds []int64
noTags := exportTransactionDataReq.TagIds == "none"
if !noTags {
allTagIds, err = a.tags.GetTagIds(exportTransactionDataReq.TagIds)
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] get transaction tag ids error, because %s", err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
}
maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
minTransactionTime := int64(0)
if exportTransactionDataReq.MaxTime > 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(exportTransactionDataReq.MaxTime)
}
if exportTransactionDataReq.MinTime > 0 {
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(exportTransactionDataReq.MinTime)
}
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, exportTransactionDataReq.TagFilterType, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, pageCountForDataExport, true)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
+65 -90
View File
@@ -1,25 +1,20 @@
package api
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"sort"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
"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"
)
// ExchangeRatesApi represents exchange rate api
type ExchangeRatesApi struct {
ApiUsingConfig
users *services.UserService
userCustomExchangeRates *services.UserCustomExchangeRatesService
}
// Initialize a exchange rate api singleton instance
@@ -28,104 +23,84 @@ var (
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
users: services.Users,
userCustomExchangeRates: services.UserCustomExchangeRates,
}
)
// LatestExchangeRateHandler returns latest exchange rate data
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
dataSource := exchangerates.Container.Current
exchangeRateResponse, err := exchangerates.Container.GetLatestExchangeRates(c, c.GetCurrentUid(), a.CurrentConfig())
if dataSource == nil {
return nil, errs.ErrInvalidExchangeRatesDataSource
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return exchangeRateResponse, nil
}
// UserCustomExchangeRateUpdateHandler updates user custom exchange rates data by request parameters for current user
func (a *ExchangeRatesApi) UserCustomExchangeRateUpdateHandler(c *core.WebContext) (any, *errs.Error) {
var customExchangeRateUpdateReq models.UserCustomExchangeRateUpdateRequest
err := c.ShouldBindJSON(&customExchangeRateUpdateReq)
if err != nil {
log.Warnf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, a.CurrentConfig().ExchangeRatesProxy)
if a.CurrentConfig().ExchangeRatesSkipTLSVerify {
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
client := &http.Client{
Transport: transport,
Timeout: time.Duration(a.CurrentConfig().ExchangeRatesRequestTimeout) * time.Millisecond,
}
requests, err := dataSource.BuildRequests()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to get user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(requests))
for i := 0; i < len(requests); i++ {
req := requests[i]
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s ", settings.Version))
resp, err := client.Do(req)
if err != nil {
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.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
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
log.Debugf(c, "[exchange_rates.LatestExchangeRateHandler] response#%d is %s", i, body)
exchangeRateResp, err := dataSource.Parse(c, body)
if err != nil {
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)
}
exchangeRateResps = append(exchangeRateResps, exchangeRateResp)
if customExchangeRateUpdateReq.Currency == user.DefaultCurrency {
return nil, errs.ErrCannotUpdateExchangeRateForDefaultCurrency
}
lastExchangeRateResponse := exchangeRateResps[len(exchangeRateResps)-1]
allExchangeRatesMap := make(map[string]string)
newCustomExchangeRate, defaultCurrencyExchangeRate, err := a.userCustomExchangeRates.UpdateCustomExchangeRate(c, uid, customExchangeRateUpdateReq.Currency, customExchangeRateUpdateReq.Rate, user.DefaultCurrency)
for i := 0; i < len(exchangeRateResps); i++ {
exchangeRateResp := exchangeRateResps[i]
for j := 0; j < len(exchangeRateResp.ExchangeRates); j++ {
exchangeRate := exchangeRateResp.ExchangeRates[j]
allExchangeRatesMap[exchangeRate.Currency] = exchangeRate.Rate
}
if err != nil {
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to update user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateUpdateReq.Currency, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allExchangeRatesMap[lastExchangeRateResponse.BaseCurrency] = "1"
allExchangeRates := make(models.LatestExchangeRateSlice, 0, len(allExchangeRatesMap))
for currency, rate := range allExchangeRatesMap {
allExchangeRates = append(allExchangeRates, &models.LatestExchangeRate{
Currency: currency,
Rate: rate,
})
}
sort.Sort(allExchangeRates)
finalExchangeRateResponse := &models.LatestExchangeRateResponse{
DataSource: lastExchangeRateResponse.DataSource,
ReferenceUrl: lastExchangeRateResponse.ReferenceUrl,
UpdateTime: lastExchangeRateResponse.UpdateTime,
BaseCurrency: lastExchangeRateResponse.BaseCurrency,
ExchangeRates: allExchangeRates,
}
return finalExchangeRateResponse, nil
log.Infof(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] user \"uid:%d\" has updated user custom exchange rate \"currency:%s\" successfully", uid, customExchangeRateUpdateReq.Currency)
return newCustomExchangeRate.ToUserCustomExchangeRateUpdateResponse(defaultCurrencyExchangeRate.Rate), nil
}
// UserCustomExchangeRateDeleteHandler deletes an existed user custom exchange rates data by request parameters for current user
func (a *ExchangeRatesApi) UserCustomExchangeRateDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var customExchangeRateDeleteReq models.UserCustomExchangeRateDeleteRequest
err := c.ShouldBindJSON(&customExchangeRateDeleteReq)
if err != nil {
log.Warnf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to get user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if customExchangeRateDeleteReq.Currency == user.DefaultCurrency {
return nil, errs.ErrCannotDeleteExchangeRateForDefaultCurrency
}
err = a.userCustomExchangeRates.DeleteCustomExchangeRate(c, uid, customExchangeRateDeleteReq.Currency)
if err != nil {
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to delete user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateDeleteReq.Currency, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] user \"uid:%d\" has deleted user custom exchange rate \"currency:%s\"", uid, customExchangeRateDeleteReq.Currency)
return true, nil
}
+374
View File
@@ -0,0 +1,374 @@
package api
import (
"bytes"
"encoding/json"
"io"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/llm"
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
"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/templates"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// LargeLanguageModelsApi represents large language models api
type LargeLanguageModelsApi struct {
ApiUsingConfig
transactionCategories *services.TransactionCategoryService
transactionTags *services.TransactionTagService
accounts *services.AccountService
users *services.UserService
}
// Initialize a large language models api singleton instance
var (
LargeLanguageModels = &LargeLanguageModelsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
transactionCategories: services.TransactionCategories,
transactionTags: services.TransactionTags,
accounts: services.Accounts,
users: services.Users,
}
)
// RecognizeReceiptImageHandler returns the recognized receipt image result
func (a *LargeLanguageModelsApi) RecognizeReceiptImageHandler(c *core.WebContext) (any, *errs.Error) {
if a.CurrentConfig().ReceiptImageRecognitionLLMConfig == nil || a.CurrentConfig().ReceiptImageRecognitionLLMConfig.LLMProvider == "" || !a.CurrentConfig().TransactionFromAIImageRecognition {
return nil, errs.ErrLargeLanguageModelProviderNotEnabled
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
timezone := time.FixedZone("Client Timezone", int(utcOffset)*60)
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return false, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION) {
return false, errs.ErrNotPermittedToPerformThisAction
}
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrParameterInvalid
}
imageFiles := form.File["image"]
if len(imageFiles) < 1 {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] there is no image in request for user \"uid:%d\"", uid)
return nil, errs.ErrNoAIRecognitionImage
}
if imageFiles[0].Size < 1 {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the size of image in request is zero for user \"uid:%d\"", uid)
return nil, errs.ErrAIRecognitionImageIsEmpty
}
if imageFiles[0].Size > int64(a.CurrentConfig().MaxAIRecognitionPictureFileSize) {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of image for user \"uid:%d\"", imageFiles[0].Size, a.CurrentConfig().MaxAIRecognitionPictureFileSize, uid)
return nil, errs.ErrExceedMaxAIRecognitionImageFileSize
}
fileExtension := utils.GetFileNameExtension(imageFiles[0].Filename)
contentType := utils.GetImageContentType(fileExtension)
if contentType == "" {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the file extension \"%s\" of image in request is not supported for user \"uid:%d\"", fileExtension, uid)
return nil, errs.ErrImageTypeNotSupported
}
imageFile, err := imageFiles[0].Open()
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get image file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
defer imageFile.Close()
imageData, err := io.ReadAll(imageFile)
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to read image file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetVisibleAccountNameMapByList(accounts)
accountNames := make([]string, 0, len(accounts))
for i := 0; i < len(accounts); i++ {
if accounts[i].Hidden || accounts[i].Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
continue
}
accountNames = append(accountNames, accounts[i].Name)
}
categories, err := a.transactionCategories.GetAllCategoriesByUid(c, uid, 0, -1)
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
incomeCategoryMap := make(map[string]*models.TransactionCategory)
incomeCategoryNames := make([]string, 0)
expenseCategoryMap := make(map[string]*models.TransactionCategory)
expenseCategoryNames := make([]string, 0)
transferCategoryMap := make(map[string]*models.TransactionCategory)
transferCategoryNames := make([]string, 0)
for i := 0; i < len(categories); i++ {
category := categories[i]
if category.Hidden || category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
continue
}
if category.Type == models.CATEGORY_TYPE_INCOME {
incomeCategoryMap[category.Name] = category
incomeCategoryNames = append(incomeCategoryNames, category.Name)
} else if category.Type == models.CATEGORY_TYPE_EXPENSE {
expenseCategoryMap[category.Name] = category
expenseCategoryNames = append(expenseCategoryNames, category.Name)
} else if category.Type == models.CATEGORY_TYPE_TRANSFER {
transferCategoryMap[category.Name] = category
transferCategoryNames = append(transferCategoryNames, category.Name)
}
}
tags, err := a.transactionTags.GetAllTagsByUid(c, uid)
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
tagNames := make([]string, 0, len(tags))
for i := 0; i < len(tags); i++ {
if tags[i].Hidden {
continue
}
tagNames = append(tagNames, tags[i].Name)
}
systemPrompt, err := templates.GetTemplate(templates.SYSTEM_PROMPT_RECEIPT_IMAGE_RECOGNITION)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
systemPromptParams := map[string]any{
"CurrentDateTime": utils.FormatUnixTimeToLongDateTime(time.Now().Unix(), timezone),
"AllExpenseCategoryNames": strings.Join(expenseCategoryNames, "\n"),
"AllIncomeCategoryNames": strings.Join(incomeCategoryNames, "\n"),
"AllTransferCategoryNames": strings.Join(transferCategoryNames, "\n"),
"AllAccountNames": strings.Join(accountNames, "\n"),
"AllTagNames": strings.Join(tagNames, "\n"),
}
var bodyBuffer bytes.Buffer
err = systemPrompt.Execute(&bodyBuffer, systemPromptParams)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
llmRequest := &data.LargeLanguageModelRequest{
Stream: false,
SystemPrompt: strings.ReplaceAll(bodyBuffer.String(), "\r\n", "\n"),
UserPrompt: imageData,
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
UserPromptContentType: contentType,
}
llmResponse, err := llm.Container.GetJsonResponseByReceiptImageRecognitionModel(c, c.GetCurrentUid(), a.CurrentConfig(), llmRequest)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if llmResponse == nil || len(llmResponse.Content) == 0 || strings.HasPrefix(llmResponse.Content, "{}") {
return nil, errs.ErrNoTransactionInformationInImage
}
var result *models.RecognizedReceiptImageResult
if err := json.Unmarshal([]byte(llmResponse.Content), &result); err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to unmarshal recognized receipt image result from llm response \"%s\" for user \"uid:%d\", because %s", llmResponse.Content, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return a.parseRecognizedReceiptImageResponse(c, uid, utcOffset, result, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (a *LargeLanguageModelsApi) parseRecognizedReceiptImageResponse(c *core.WebContext, uid int64, utcOffset int16, recognizedResult *models.RecognizedReceiptImageResult, 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.RecognizedReceiptImageResponse, *errs.Error) {
recognizedReceiptImageResponse := &models.RecognizedReceiptImageResponse{
Type: models.TRANSACTION_TYPE_EXPENSE,
}
if recognizedResult == nil {
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed result is null")
return nil, errs.ErrNoTransactionInformationInImage
}
if recognizedResult.Type == "income" {
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_INCOME
if len(recognizedResult.CategoryName) > 0 {
category, exists := incomeCategoryMap[recognizedResult.CategoryName]
if exists {
recognizedReceiptImageResponse.CategoryId = category.CategoryId
}
}
} else if recognizedResult.Type == "expense" {
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_EXPENSE
if len(recognizedResult.CategoryName) > 0 {
category, exists := expenseCategoryMap[recognizedResult.CategoryName]
if exists {
recognizedReceiptImageResponse.CategoryId = category.CategoryId
}
}
} else if recognizedResult.Type == "transfer" {
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_TRANSFER
if len(recognizedResult.CategoryName) > 0 {
category, exists := transferCategoryMap[recognizedResult.CategoryName]
if exists {
recognizedReceiptImageResponse.CategoryId = category.CategoryId
}
}
} else if len(recognizedResult.Type) == 0 {
return nil, errs.ErrNoTransactionInformationInImage
} else {
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed transaction type \"%s\" is invalid", recognizedResult.Type)
return nil, errs.ErrOperationFailed
}
if len(recognizedResult.Time) > 0 {
longDateTime := a.getLongDateTime(recognizedResult.Time)
timestamp, err := utils.ParseFromLongDateTime(longDateTime, utcOffset)
if err != nil {
log.Warnf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed time \"%s\" is invalid", recognizedResult.Time)
} else {
recognizedReceiptImageResponse.Time = timestamp.Unix()
}
}
if len(recognizedResult.Amount) > 0 {
amount, err := utils.ParseAmount(recognizedResult.Amount)
if err != nil {
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed amount \"%s\" is invalid", recognizedResult.Amount)
return nil, errs.ErrOperationFailed
}
recognizedReceiptImageResponse.SourceAmount = amount
if recognizedReceiptImageResponse.Type == models.TRANSACTION_TYPE_TRANSFER && len(recognizedResult.DestinationAmount) > 0 {
destinationAmount, err := utils.ParseAmount(recognizedResult.DestinationAmount)
if err != nil {
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed destination amount \"%s\" is invalid", recognizedResult.DestinationAmount)
return nil, errs.ErrOperationFailed
}
recognizedReceiptImageResponse.DestinationAmount = destinationAmount
}
}
if len(recognizedResult.AccountName) > 0 {
account, exists := accountMap[recognizedResult.AccountName]
if exists {
recognizedReceiptImageResponse.SourceAccountId = account.AccountId
}
}
if len(recognizedResult.DestinationAccountName) > 0 {
account, exists := accountMap[recognizedResult.DestinationAccountName]
if exists {
recognizedReceiptImageResponse.DestinationAccountId = account.AccountId
}
}
if len(recognizedResult.TagNames) > 0 {
tagIds := make([]string, 0, len(recognizedResult.TagNames))
for i := 0; i < len(recognizedResult.TagNames); i++ {
tagName := recognizedResult.TagNames[i]
tag, exists := tagMap[tagName]
if exists {
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
}
}
recognizedReceiptImageResponse.TagIds = tagIds
}
if len(recognizedResult.Description) > 0 {
recognizedReceiptImageResponse.Comment = recognizedResult.Description
}
return recognizedReceiptImageResponse, nil
}
func (a *LargeLanguageModelsApi) getLongDateTime(dateTime string) string {
if utils.IsValidLongDateTimeFormat(dateTime) {
return dateTime
}
if utils.IsValidLongDateTimeWithoutSecondFormat(dateTime) {
return dateTime + ":00"
}
if utils.IsValidLongDateFormat(dateTime) {
return dateTime + " 00:00:00"
}
return dateTime
}
+265
View File
@@ -0,0 +1,265 @@
package api
import (
"encoding/json"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/mcp"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const mcpServerName = "ezBookkeeping-mcp"
// ModelContextProtocolAPI represents model context protocol api
type ModelContextProtocolAPI struct {
ApiUsingConfig
transactions *services.TransactionService
transactionCategories *services.TransactionCategoryService
transactionTags *services.TransactionTagService
accounts *services.AccountService
users *services.UserService
tokens *services.TokenService
}
// Initialize a model context protocol api singleton instance
var (
ModelContextProtocols = &ModelContextProtocolAPI{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
transactions: services.Transactions,
transactionCategories: services.TransactionCategories,
transactionTags: services.TransactionTags,
accounts: services.Accounts,
users: services.Users,
tokens: services.Tokens,
}
)
// InitializeHandler returns the initialize response for model context protocol
func (a *ModelContextProtocolAPI) InitializeHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
var initRequest mcp.MCPInitializeRequest
if jsonRPCRequest.Params != nil {
if err := json.Unmarshal(jsonRPCRequest.Params, &initRequest); err != nil {
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
} else {
return nil, errs.ErrIncompleteOrIncorrectSubmission
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Warnf(c, "[model_context_protocols.InitializeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
tokenClaims := c.GetTokenClaims()
userTokenId, err := utils.StringToInt64(tokenClaims.UserTokenId)
if err != nil {
log.Warnf(c, "[model_context_protocols.InitializeHandler] parse user token id failed, because %s", err.Error())
} else {
tokenRecord := &models.TokenRecord{
Uid: tokenClaims.Uid,
UserTokenId: userTokenId,
CreatedUnixTime: tokenClaims.IssuedAt,
}
tokenId := a.tokens.GenerateTokenId(tokenRecord)
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
if err != nil {
log.Warnf(c, "[model_context_protocols.InitializeHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
}
}
protocolVersion := mcp.MCPProtocolVersion(initRequest.ProtocolVersion)
_, exists := mcp.SupportedMCPVersion[protocolVersion]
if !exists {
protocolVersion = mcp.LatestSupportedMCPVersion
}
initResp := mcp.MCPInitializeResponse{
ProtocolVersion: string(protocolVersion),
Capabilities: &mcp.MCPCapabilities{
Tools: &mcp.MCPToolCapabilities{
ListChanged: false,
},
},
ServerInfo: &mcp.MCPImplementation{
Name: mcpServerName,
Title: a.CurrentConfig().AppName,
Version: settings.Version,
},
}
return initResp, nil
}
// ListResourcesHandler returns the list of resources for model context protocol
func (a *ModelContextProtocolAPI) ListResourcesHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Warnf(c, "[model_context_protocols.ListResourcesHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
listResourcesResp := mcp.MCPListResourcesResponse{
Resources: make([]*mcp.MCPResource, 0),
}
return listResourcesResp, nil
}
// ReadResourceHandler returns the resource details for a specific resource in model context protocol
func (a *ModelContextProtocolAPI) ReadResourceHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
var readResourceReq mcp.MCPReadResourceRequest
if jsonRPCRequest.Params != nil {
if err := json.Unmarshal(jsonRPCRequest.Params, &readResourceReq); err != nil {
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
} else {
return nil, errs.ErrIncompleteOrIncorrectSubmission
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Warnf(c, "[model_context_protocols.ReadResourceHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
return nil, errs.ErrApiNotFound
}
// ListToolsHandler returns the list of tools for model context protocol
func (a *ModelContextProtocolAPI) ListToolsHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Warnf(c, "[model_context_protocols.ListToolsHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
mcpVersion := a.getMCPVersion(c)
toolsInfo := mcp.Container.GetMCPTools()
finalToolsInfos := make([]*mcp.MCPTool, len(toolsInfo))
for i := 0; i < len(toolsInfo); i++ {
finalToolsInfos[i] = &mcp.MCPTool{
Name: toolsInfo[i].Name,
InputSchema: toolsInfo[i].InputSchema,
Title: toolsInfo[i].Title,
Description: toolsInfo[i].Description,
}
if mcpVersion >= string(mcp.ToolResultStructuredContentMinVersion) {
finalToolsInfos[i].OutputSchema = toolsInfo[i].OutputSchema
}
}
listToolsResp := mcp.MCPListToolsResponse{
Tools: finalToolsInfos,
}
return listToolsResp, nil
}
// CallToolHandler returns the result of calling a specific tool for model context protocol
func (a *ModelContextProtocolAPI) CallToolHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Warnf(c, "[model_context_protocols.CallToolHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
var callToolReq mcp.MCPCallToolRequest
if jsonRPCRequest.Params != nil {
if err := json.Unmarshal(jsonRPCRequest.Params, &callToolReq); err != nil {
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
} else {
return nil, errs.ErrIncompleteOrIncorrectSubmission
}
result, err := mcp.Container.HandleTool(c, &callToolReq, user, a.CurrentConfig(), a)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return result, nil
}
// PingHandler return the ping response for model context protocol
func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
return core.O{}, nil
}
// GetTransactionService implements the MCPAvailableServices interface
func (a *ModelContextProtocolAPI) GetTransactionService() *services.TransactionService {
return a.transactions
}
// GetTransactionCategoryService implements the MCPAvailableServices interface
func (a *ModelContextProtocolAPI) GetTransactionCategoryService() *services.TransactionCategoryService {
return a.transactionCategories
}
// GetTransactionTagService implements the MCPAvailableServices interface
func (a *ModelContextProtocolAPI) GetTransactionTagService() *services.TransactionTagService {
return a.transactionTags
}
// GetAccountService implements the MCPAvailableServices interface
func (a *ModelContextProtocolAPI) GetAccountService() *services.AccountService {
return a.accounts
}
// GetUserService implements the MCPAvailableServices interface
func (a *ModelContextProtocolAPI) GetUserService() *services.UserService {
return a.users
}
// getMCPVersion returns the MCP protocol version from the request header
func (a *ModelContextProtocolAPI) getMCPVersion(c *core.WebContext) string {
return c.GetHeader(mcp.MCPProtocolVersionHeaderName)
}
+10
View File
@@ -43,6 +43,16 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext)
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
if config.EnableMCPServer {
a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer)
}
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
if config.TransactionFromAIImageRecognition {
a.appendBooleanSetting(builder, "llmt", config.TransactionFromAIImageRecognition)
}
}
if config.LoginPageTips.Enabled {
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// SystemsApi represents system api
type SystemsApi struct{}
// Initialize a system api singleton instance
var (
Systems = &SystemsApi{}
)
// VersionHandler returns the server version and commit hash
func (a *SystemsApi) VersionHandler(c *core.WebContext) (any, *errs.Error) {
result := make(map[string]string)
result["version"] = settings.Version
result["commitHash"] = settings.CommitHash
if settings.BuildTime != "" {
result["buildTime"] = settings.BuildTime
}
return result, nil
}
+106 -27
View File
@@ -18,8 +18,9 @@ import (
type TokensApi struct {
ApiUsingConfig
ApiWithUserInfo
tokens *services.TokenService
users *services.UserService
tokens *services.TokenService
users *services.UserService
userAppCloudSettings *services.UserApplicationCloudSettingsService
}
// Initialize a token api singleton instance
@@ -36,15 +37,16 @@ var (
container: avatars.Container,
},
},
tokens: services.Tokens,
users: services.Users,
tokens: services.Tokens,
users: services.Users,
userAppCloudSettings: services.UserApplicationCloudSettings,
}
)
// TokenListHandler returns available token list of current user
func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tokens, err := a.tokens.GetAllUnexpiredNormalTokensByUid(c, uid)
tokens, err := a.tokens.GetAllUnexpiredNormalAndMCPTokensByUid(c, uid)
if err != nil {
log.Errorf(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
@@ -67,6 +69,10 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
tokenResp.IsCurrent = true
}
if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != services.TokenUserAgentCreatedViaCli {
tokenResp.UserAgent = services.TokenUserAgentForMCP
}
tokenResps[i] = tokenResp
}
@@ -75,9 +81,62 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
return tokenResps, nil
}
// TokenGenerateMCPHandler generates a new MCP token for current user
func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableMCPServer {
return nil, errs.ErrMCPServerNotEnabled
}
var generateMCPTokenReq models.TokenGenerateMCPRequest
err := c.ShouldBindJSON(&generateMCPTokenReq)
if err != nil {
log.Warnf(c, "[tokens.TokenGenerateMCPHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Warnf(c, "[tokens.TokenGenerateMCPHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
return false, errs.ErrNotPermittedToPerformThisAction
}
if !a.users.IsPasswordEqualsUserPassword(generateMCPTokenReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
token, claims, err := a.tokens.CreateMCPToken(c, user)
if err != nil {
log.Errorf(c, "[tokens.TokenGenerateMCPHandler] failed to create mcp token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrTokenGenerating)
}
log.Infof(c, "[tokens.TokenGenerateMCPHandler] user \"uid:%d\" has generated mcp token, new token will be expired at %d", user.Uid, claims.ExpiresAt)
generateMCPTokenResp := &models.TokenGenerateMCPResponse{
Token: token,
MCPUrl: a.CurrentConfig().RootUrl + "mcp",
}
return generateMCPTokenResp, nil
}
// TokenRevokeCurrentHandler revokes current token of current user
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
_, claims, err := a.tokens.ParseTokenByHeader(c)
tokenString := c.GetTokenStringFromHeader()
if tokenString == "" {
return false, errs.ErrTokenIsEmpty
}
_, claims, err := a.tokens.ParseToken(c, tokenString)
if err != nil {
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
@@ -100,11 +159,11 @@ func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Er
err = a.tokens.DeleteToken(c, tokenRecord)
if err != nil {
log.Errorf(c, "[token.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
log.Errorf(c, "[tokens.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.Infof(c, "[token.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", claims.Uid, tokenId)
log.Infof(c, "[tokens.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", claims.Uid, tokenId)
return true, nil
}
@@ -122,7 +181,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[token.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
log.Errorf(c, "[tokens.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
}
return nil, errs.Or(err, errs.ErrInvalidTokenId)
@@ -131,7 +190,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
if tokenRecord.Uid != uid {
log.Warnf(c, "[token.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
log.Warnf(c, "[tokens.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
return nil, errs.ErrInvalidTokenId
}
@@ -140,7 +199,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[token.TokenRevokeHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[tokens.TokenRevokeHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
@@ -154,11 +213,11 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
err = a.tokens.DeleteToken(c, tokenRecord)
if err != nil {
log.Errorf(c, "[token.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
log.Errorf(c, "[tokens.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.Infof(c, "[token.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
log.Infof(c, "[tokens.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
return true, nil
}
@@ -194,7 +253,7 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
@@ -207,11 +266,11 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error)
err = a.tokens.DeleteTokens(c, uid, tokens)
if err != nil {
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[token.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
log.Infof(c, "[tokens.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
return true, nil
}
@@ -221,7 +280,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Warnf(c, "[token.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
@@ -229,7 +288,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
oldTokenClaims := c.GetTokenClaims()
if now-oldTokenClaims.IssuedAt < int64(a.CurrentConfig().TokenMinRefreshInterval) {
log.Infof(c, "[token.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid)
log.Infof(c, "[tokens.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid)
userTokenId, err := utils.StringToInt64(oldTokenClaims.UserTokenId)
@@ -247,13 +306,23 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
if err != nil {
log.Warnf(c, "[token.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
}
}
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
if err != nil {
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
}
refreshResp := &models.TokenRefreshResponse{
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
User: a.GetUserBasicInfo(user),
ApplicationCloudSettings: applicationCloudSettingSlice,
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
}
return refreshResp, nil
@@ -262,7 +331,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.Errorf(c, "[token.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[tokens.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrTokenGenerating)
}
@@ -276,13 +345,23 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.Infof(c, "[token.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
if err != nil {
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
}
log.Infof(c, "[tokens.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: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
NewToken: token,
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
User: a.GetUserBasicInfo(user),
ApplicationCloudSettings: applicationCloudSettingSlice,
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
}
return refreshResp, nil
+9 -6
View File
@@ -29,6 +29,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
categories: services.TransactionCategories,
@@ -161,7 +164,7 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.WebContext) (an
log.Infof(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId))
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId))
categoryResp := category.ToTransactionCategoryInfoResponse()
return categoryResp, nil
@@ -226,11 +229,11 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (an
return nil, errs.ErrNothingWillBeUpdated
}
if category.ParentCategoryId == 0 && newCategory.ParentCategoryId != 0 {
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId && newCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionCategoryToSecondary)
}
if category.ParentCategoryId != 0 && newCategory.ParentCategoryId == 0 {
if category.ParentCategoryId != models.LevelOneTransactionCategoryParentId && newCategory.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
return nil, errs.Or(err, errs.ErrNotAllowChangeSecondaryTransactionCategoryToPrimary)
}
@@ -253,7 +256,7 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (an
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionType)
}
if toPrimaryCategory.ParentCategoryId != 0 {
if toPrimaryCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
return nil, errs.Or(err, errs.ErrNotAllowUseSecondaryTransactionAsPrimaryCategory)
}
}
@@ -430,7 +433,7 @@ func (a *TransactionCategoriesApi) getTransactionCategoryListByTypeResponse(cate
for i := 0; i < len(categoryResps); i++ {
categoryResp := categoryResps[i]
if categoryResp.ParentId <= models.LevelOneTransactionParentId {
if categoryResp.ParentId <= models.LevelOneTransactionCategoryParentId {
continue
}
@@ -446,7 +449,7 @@ func (a *TransactionCategoriesApi) getTransactionCategoryListByTypeResponse(cate
finalCategoryResps := make(models.TransactionCategoryInfoResponseSlice, 0)
for i := 0; i < len(categoryResps); i++ {
if parentId <= 0 && categoryResps[i].ParentId == models.LevelOneTransactionParentId {
if parentId <= 0 && categoryResps[i].ParentId == models.LevelOneTransactionCategoryParentId {
sort.Sort(categoryResps[i].SubCategories)
finalCategoryResps = append(finalCategoryResps, categoryResps[i])
} else if parentId > 0 && categoryResps[i].ParentId == parentId {
+4 -1
View File
@@ -26,6 +26,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
users: services.Users,
@@ -112,7 +115,7 @@ func (a *TransactionPicturesApi) TransactionPictureUploadHandler(c *core.WebCont
return nil, errs.Or(err, errs.ErrOperationFailed)
}
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId, utils.Int64ToString(pictureInfo.PictureId))
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId, utils.Int64ToString(pictureInfo.PictureId))
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
return pictureInfoResp, nil
+53
View File
@@ -101,6 +101,47 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er
return tagResp, nil
}
// TagCreateBatchHandler saves some new transaction tags by request parameters for current user
func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *errs.Error) {
var tagCreateBatchReq models.TransactionTagCreateBatchRequest
err := c.ShouldBindJSON(&tagCreateBatchReq)
if err != nil {
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
if err != nil {
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tags := a.createNewTagModels(uid, &tagCreateBatchReq, maxOrderId+1)
err = a.tags.CreateTags(c, uid, tags, tagCreateBatchReq.SkipExists)
if err != nil {
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to create tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tags.TagCreateBatchHandler] user \"uid:%d\" has created tags successfully", uid)
tagResps := make(models.TransactionTagInfoResponseSlice, len(tags))
for i := 0; i < len(tags); i++ {
tagResps[i] = tags[i].ToTransactionTagInfoResponse()
}
sort.Sort(tagResps)
return tagResps, nil
}
// TagModifyHandler saves an existed transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Error) {
var tagModifyReq models.TransactionTagModifyRequest
@@ -230,3 +271,15 @@ func (a *TransactionTagsApi) createNewTagModel(uid int64, tagCreateReq *models.T
DisplayOrder: order,
}
}
func (a *TransactionTagsApi) createNewTagModels(uid int64, tagCreateBatchReq *models.TransactionTagCreateBatchRequest, order int32) []*models.TransactionTag {
tags := make([]*models.TransactionTag, len(tagCreateBatchReq.Tags))
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
tagCreateReq := tagCreateBatchReq.Tags[i]
tag := a.createNewTagModel(uid, tagCreateReq, order+int32(i))
tags[i] = tag
}
return tags
}
+68 -4
View File
@@ -31,6 +31,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
templates: services.TransactionTemplates,
@@ -156,7 +159,12 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any
}
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
template := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
template, err := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create new template for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
@@ -189,7 +197,7 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any
log.Infof(c, "[transaction_templates.TemplateCreateHandler] user \"uid:%d\" has created a new template \"id:%d\" successfully", uid, template.TemplateId)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId, utils.Int64ToString(template.TemplateId))
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId, utils.Int64ToString(template.TemplateId))
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
@@ -260,6 +268,34 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
if templateModifyReq.ScheduledStartDate != nil {
startTime, err := utils.ParseFromLongDateFirstTime(*templateModifyReq.ScheduledStartDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled start date for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
startUnixTime := startTime.Unix()
newTemplate.ScheduledStartTime = &startUnixTime
}
if templateModifyReq.ScheduledEndDate != nil {
endTime, err := utils.ParseFromLongDateLastTime(*templateModifyReq.ScheduledEndDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled end date for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
endUnixTime := endTime.Unix()
newTemplate.ScheduledEndTime = &endUnixTime
}
if newTemplate.ScheduledStartTime != nil && newTemplate.ScheduledEndTime != nil && *newTemplate.ScheduledStartTime > *newTemplate.ScheduledEndTime {
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
}
}
if newTemplate.Name == template.Name &&
@@ -277,6 +313,8 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
newTemplate.ScheduledStartTime == template.ScheduledStartTime &&
newTemplate.ScheduledEndTime == template.ScheduledEndTime &&
newTemplate.ScheduledAt == template.ScheduledAt &&
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
return nil, errs.ErrNothingWillBeUpdated
@@ -419,7 +457,7 @@ func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any
return true, nil
}
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) *models.TransactionTemplate {
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) (*models.TransactionTemplate, error) {
template := &models.TransactionTemplate{
Uid: uid,
TemplateType: templateCreateReq.TemplateType,
@@ -441,9 +479,35 @@ func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCrea
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
if templateCreateReq.ScheduledStartDate != nil {
startTime, err := utils.ParseFromLongDateFirstTime(*templateCreateReq.ScheduledStartDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
if err != nil {
return nil, err
}
startUnixTime := startTime.Unix()
template.ScheduledStartTime = &startUnixTime
}
if templateCreateReq.ScheduledEndDate != nil {
endTime, err := utils.ParseFromLongDateLastTime(*templateCreateReq.ScheduledEndDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
if err != nil {
return nil, err
}
endUnixTime := endTime.Unix()
template.ScheduledEndTime = &endUnixTime
}
if template.ScheduledStartTime != nil && template.ScheduledEndTime != nil && *template.ScheduledStartTime > *template.ScheduledEndTime {
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
}
}
return template
return template, nil
}
func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
+422 -170
View File
@@ -1,6 +1,8 @@
package api
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
@@ -8,6 +10,8 @@ import (
orderedmap "github.com/wk8/go-ordered-map/v2"
"github.com/mayswind/ezbookkeeping/pkg/converters"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -18,8 +22,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const maximumTagsCountOfTransaction = 10
const maximumPicturesCountOfTransaction = 10
const pageCountForAccountStatement = 1000
// TransactionsApi represents transaction api
type TransactionsApi struct {
@@ -40,6 +43,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
transactions: services.Transactions,
@@ -63,14 +69,14 @@ func (a *TransactionsApi) TransactionCountHandler(c *core.WebContext) (any, *err
uid := c.GetCurrentUid()
allAccountIds, err := a.getAccountOrSubAccountIds(c, transactionCountReq.AccountIds, uid)
allAccountIds, err := a.accounts.GetAccountOrSubAccountIds(c, transactionCountReq.AccountIds, uid)
if err != nil {
log.Warnf(c, "[transactions.TransactionCountHandler] get account error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allCategoryIds, err := a.getCategoryOrSubCategoryIds(c, transactionCountReq.CategoryIds, uid)
allCategoryIds, err := a.transactionCategories.GetCategoryOrSubCategoryIds(c, transactionCountReq.CategoryIds, uid)
if err != nil {
log.Warnf(c, "[transactions.TransactionCountHandler] get transaction category error, because %s", err.Error())
@@ -81,7 +87,7 @@ func (a *TransactionsApi) TransactionCountHandler(c *core.WebContext) (any, *err
noTags := transactionCountReq.TagIds == "none"
if !noTags {
allTagIds, err = a.getTagIds(transactionCountReq.TagIds)
allTagIds, err = a.transactionTags.GetTagIds(transactionCountReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionCountHandler] get transaction tag ids error, because %s", err.Error())
@@ -131,14 +137,14 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
return nil, errs.ErrUserNotFound
}
allAccountIds, err := a.getAccountOrSubAccountIds(c, transactionListReq.AccountIds, uid)
allAccountIds, err := a.accounts.GetAccountOrSubAccountIds(c, transactionListReq.AccountIds, uid)
if err != nil {
log.Warnf(c, "[transactions.TransactionListHandler] get account error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allCategoryIds, err := a.getCategoryOrSubCategoryIds(c, transactionListReq.CategoryIds, uid)
allCategoryIds, err := a.transactionCategories.GetCategoryOrSubCategoryIds(c, transactionListReq.CategoryIds, uid)
if err != nil {
log.Warnf(c, "[transactions.TransactionListHandler] get transaction category error, because %s", err.Error())
@@ -149,7 +155,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
noTags := transactionListReq.TagIds == "none"
if !noTags {
allTagIds, err = a.getTagIds(transactionListReq.TagIds)
allTagIds, err = a.transactionTags.GetTagIds(transactionListReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionListHandler] get transaction tag ids error, because %s", err.Error())
@@ -234,14 +240,14 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
return nil, errs.ErrUserNotFound
}
allAccountIds, err := a.getAccountOrSubAccountIds(c, transactionListReq.AccountIds, uid)
allAccountIds, err := a.accounts.GetAccountOrSubAccountIds(c, transactionListReq.AccountIds, uid)
if err != nil {
log.Warnf(c, "[transactions.TransactionMonthListHandler] get account error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allCategoryIds, err := a.getCategoryOrSubCategoryIds(c, transactionListReq.CategoryIds, uid)
allCategoryIds, err := a.transactionCategories.GetCategoryOrSubCategoryIds(c, transactionListReq.CategoryIds, uid)
if err != nil {
log.Warnf(c, "[transactions.TransactionMonthListHandler] get transaction category error, because %s", err.Error())
@@ -252,7 +258,7 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
noTags := transactionListReq.TagIds == "none"
if !noTags {
allTagIds, err = a.getTagIds(transactionListReq.TagIds)
allTagIds, err = a.transactionTags.GetTagIds(transactionListReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionMonthListHandler] get transaction tag ids error, because %s", err.Error())
@@ -282,6 +288,114 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
return transactionResps, nil
}
// TransactionReconciliationStatementHandler returns transaction reconciliation statement list of current user
func (a *TransactionsApi) TransactionReconciliationStatementHandler(c *core.WebContext) (any, *errs.Error) {
var reconciliationStatementRequest models.TransactionReconciliationStatementRequest
err := c.ShouldBindQuery(&reconciliationStatementRequest)
if err != nil {
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
account, err := a.accounts.GetAccountByAccountId(c, uid, reconciliationStatementRequest.AccountId)
if err != nil {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.AccountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if account.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] account \"id:%d\" for user \"uid:%d\" is not a single account", reconciliationStatementRequest.AccountId, uid)
return nil, errs.ErrAccountTypeInvalid
}
maxTransactionTime := int64(0)
if reconciliationStatementRequest.EndTime > 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(reconciliationStatementRequest.EndTime)
}
minTransactionTime := int64(0)
if reconciliationStatementRequest.StartTime > 0 {
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(reconciliationStatementRequest.StartTime)
}
transactionsWithAccountBalance, totalInflows, totalOutflows, openingBalance, closingBalance, err := a.transactions.GetAllTransactionsWithAccountBalanceByMaxTime(c, uid, pageCountForAccountStatement, maxTransactionTime, minTransactionTime, reconciliationStatementRequest.AccountId, account.Category)
if err != nil {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get transactions from \"%d\" to \"%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.StartTime, reconciliationStatementRequest.EndTime, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
transactions := make([]*models.Transaction, len(transactionsWithAccountBalance))
transactionAccountBalanceMap := make(map[int64]*models.TransactionWithAccountBalance, len(transactionsWithAccountBalance))
for i := 0; i < len(transactionsWithAccountBalance); i++ {
transactionWithBalance := transactionsWithAccountBalance[i]
transactions[i] = transactionWithBalance.Transaction
transactionAccountBalanceMap[transactionWithBalance.TransactionId] = transactionWithBalance
transactionAccountBalanceMap[transactionWithBalance.RelatedId] = transactionWithBalance
}
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, utcOffset, false, true, true, true)
if err != nil {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
responseItems := make([]*models.TransactionReconciliationStatementResponseItem, len(transactionResult))
for i := 0; i < len(transactionResult); i++ {
transactionResult := transactionResult[i]
accountOpeningBalance := int64(0)
accountClosingBalance := int64(0)
if transactionWithBalance, exists := transactionAccountBalanceMap[transactionResult.Id]; exists {
accountOpeningBalance = transactionWithBalance.AccountOpeningBalance
accountClosingBalance = transactionWithBalance.AccountClosingBalance
} else {
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] missing account balance for transaction \"id:%d\" of user \"uid:%d\"", transactionResult.Id, uid)
}
responseItems[i] = &models.TransactionReconciliationStatementResponseItem{
TransactionInfoResponse: transactionResult,
AccountOpeningBalance: accountOpeningBalance,
AccountClosingBalance: accountClosingBalance,
}
}
reconciliationStatementResp := &models.TransactionReconciliationStatementResponse{
Transactions: responseItems,
TotalInflows: totalInflows,
TotalOutflows: totalOutflows,
OpeningBalance: openingBalance,
ClosingBalance: closingBalance,
}
return reconciliationStatementResp, nil
}
// TransactionStatisticsHandler returns transaction statistics of current user
func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
var statisticReq models.TransactionStatisticRequest
@@ -303,7 +417,7 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any,
noTags := statisticReq.TagIds == "none"
if !noTags {
allTagIds, err = a.getTagIds(statisticReq.TagIds)
allTagIds, err = a.transactionTags.GetTagIds(statisticReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsHandler] get transaction tag ids error, because %s", err.Error())
@@ -312,7 +426,7 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any,
}
uid := c.GetCurrentUid()
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, allTagIds, noTags, statisticReq.TagFilterType, utcOffset, statisticReq.UseTransactionTimezone)
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, allTagIds, noTags, statisticReq.TagFilterType, statisticReq.Keyword, utcOffset, statisticReq.UseTransactionTimezone)
if err != nil {
log.Errorf(c, "[transactions.TransactionStatisticsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
@@ -366,7 +480,7 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
noTags := statisticTrendsReq.TagIds == "none"
if !noTags {
allTagIds, err = a.getTagIds(statisticTrendsReq.TagIds)
allTagIds, err = a.transactionTags.GetTagIds(statisticTrendsReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsTrendsHandler] get transaction tag ids error, because %s", err.Error())
@@ -375,17 +489,17 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
}
uid := c.GetCurrentUid()
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, allTagIds, noTags, statisticTrendsReq.TagFilterType, utcOffset, statisticTrendsReq.UseTransactionTimezone)
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, allTagIds, noTags, statisticTrendsReq.TagFilterType, statisticTrendsReq.Keyword, utcOffset, statisticTrendsReq.UseTransactionTimezone)
if err != nil {
log.Errorf(c, "[transactions.TransactionStatisticsTrendsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
statisticTrendsResp := make(models.TransactionStatisticTrendsItemSlice, 0, len(allMonthlyTotalAmounts))
statisticTrendsResp := make(models.TransactionStatisticTrendsResponseItemSlice, 0, len(allMonthlyTotalAmounts))
for yearMonth, monthlyTotalAmounts := range allMonthlyTotalAmounts {
monthlyStatisticResp := &models.TransactionStatisticTrendsItem{
monthlyStatisticResp := &models.TransactionStatisticTrendsResponseItem{
Year: yearMonth / 100,
Month: yearMonth % 100,
Items: make([]*models.TransactionStatisticResponseItem, len(monthlyTotalAmounts)),
@@ -435,6 +549,25 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
return nil, errs.ErrQueryItemsTooMuch
}
excludeAccountIds := make([]int64, 0)
excludeCategoryIds := make([]int64, 0)
if transactionAmountsReq.ExcludeAccountIds != "" {
excludeAccountIds, err = utils.StringArrayToInt64Array(strings.Split(transactionAmountsReq.ExcludeAccountIds, ","))
if err != nil {
return nil, errs.ErrAccountIdInvalid
}
}
if transactionAmountsReq.ExcludeCategoryIds != "" {
excludeCategoryIds, err = utils.StringArrayToInt64Array(strings.Split(transactionAmountsReq.ExcludeCategoryIds, ","))
if err != nil {
return nil, errs.ErrTransactionCategoryIdInvalid
}
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
@@ -457,7 +590,7 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
for i := 0; i < len(requestItems); i++ {
requestItem := requestItems[i]
incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, utcOffset, transactionAmountsReq.UseTransactionTimezone)
incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, excludeAccountIds, excludeCategoryIds, utcOffset, transactionAmountsReq.UseTransactionTimezone)
if err != nil {
log.Errorf(c, "[transactions.TransactionAmountsHandler] failed to get transaction amounts item for user \"uid:%d\", because %s", uid, err.Error())
@@ -610,7 +743,7 @@ func (a *TransactionsApi) TransactionGetHandler(c *core.WebContext) (any, *errs.
}
if !transactionGetReq.TrimTag {
tagMap, err = a.transactionTags.GetTagsByTagIds(c, uid, utils.ToUniqueInt64Slice(a.getTransactionTagIds(allTransactionTagIds)))
tagMap, err = a.transactionTags.GetTagsByTagIds(c, uid, utils.ToUniqueInt64Slice(a.transactionTags.GetTransactionTagIds(allTransactionTagIds)))
if err != nil {
log.Errorf(c, "[transactions.TransactionGetHandler] failed to get transactions tags for user \"uid:%d\", because %s", uid, err.Error())
@@ -675,7 +808,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTagIdInvalid
}
if len(tagIds) > maximumTagsCountOfTransaction {
if len(tagIds) > models.MaximumTagsCountOfTransaction {
return nil, errs.ErrTransactionHasTooManyTags
}
@@ -686,7 +819,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionPictureIdInvalid
}
if len(pictureIds) > maximumPicturesCountOfTransaction {
if len(pictureIds) > models.MaximumPicturesCountOfTransaction {
return nil, errs.ErrTransactionHasTooManyPictures
}
@@ -695,7 +828,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTypeInvalid
}
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 {
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId != 0 {
log.Warnf(c, "[transactions.TransactionCreateHandler] balance modification transaction cannot set category id")
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
}
@@ -781,7 +914,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
log.Infof(c, "[transactions.TransactionCreateHandler] user \"uid:%d\" has created a new transaction \"id:%d\" successfully", uid, transaction.TransactionId)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION, uid, transactionCreateReq.ClientSessionId, utils.Int64ToString(transaction.TransactionId))
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION, uid, transactionCreateReq.ClientSessionId, utils.Int64ToString(transaction.TransactionId))
transactionResp := transaction.ToTransactionInfoResponse(tagIds, transactionEditable)
transactionResp.Pictures = a.GetTransactionPictureInfoResponseList(pictureInfos)
@@ -805,7 +938,7 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTagIdInvalid
}
if len(tagIds) > maximumTagsCountOfTransaction {
if len(tagIds) > models.MaximumTagsCountOfTransaction {
return nil, errs.ErrTransactionHasTooManyTags
}
@@ -816,7 +949,7 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionPictureIdInvalid
}
if len(pictureIds) > maximumPicturesCountOfTransaction {
if len(pictureIds) > models.MaximumPicturesCountOfTransaction {
return nil, errs.ErrTransactionHasTooManyPictures
}
@@ -843,6 +976,14 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTypeInvalid
}
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE && transactionModifyReq.CategoryId != 0 {
log.Warnf(c, "[transactions.TransactionModifyHandler] balance modification transaction cannot set category id")
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
} else if transaction.Type != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE && transactionModifyReq.CategoryId == 0 {
log.Warnf(c, "[transactions.TransactionModifyHandler] non-balance modification transaction must set category id")
return nil, errs.ErrIncompleteOrIncorrectSubmission
}
allTransactionTagIds, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, []int64{transaction.TransactionId})
if err != nil {
@@ -1030,6 +1171,83 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
return true, nil
}
// TransactionParseImportDsvFileDataHandler returns the parsed file data by request parameters for current user
func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrParameterInvalid
}
fileTypes := form.Value["fileType"]
if len(fileTypes) < 1 || fileTypes[0] == "" {
return nil, errs.ErrImportFileTypeIsEmpty
}
fileType := fileTypes[0]
if !converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
}
fileEncodings := form.Value["fileEncoding"]
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
return nil, errs.ErrImportFileEncodingIsEmpty
}
fileEncoding := fileEncodings[0]
dataParser, err := converters.CreateNewDelimiterSeparatedValuesDataParser(fileType, fileEncoding)
if err != nil {
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
}
importFiles := form.File["file"]
if len(importFiles) < 1 {
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] there is no import file in request for user \"uid:%d\"", uid)
return nil, errs.ErrNoFilesUpload
}
if importFiles[0].Size < 1 {
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the size of import file in request is zero for user \"uid:%d\"", uid)
return nil, errs.ErrUploadedFileEmpty
}
if importFiles[0].Size > int64(a.CurrentConfig().MaxImportFileSize) {
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of import file for user \"uid:%d\"", importFiles[0].Size, a.CurrentConfig().MaxImportFileSize, uid)
return nil, errs.ErrExceedMaxUploadFileSize
}
importFile, err := importFiles[0].Open()
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
defer importFile.Close()
fileData, err := io.ReadAll(importFile)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allLines, err := dataParser.ParseDsvFileLines(c, fileData)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to parse import file data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return allLines, nil
}
// TransactionParseImportFileHandler returns the parsed transaction data by request parameters for current user
func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
@@ -1054,7 +1272,105 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
}
fileType := fileTypes[0]
dataImporter, err := converters.GetTransactionDataImporter(fileType)
var dataImporter converter.TransactionDataImporter
if converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
fileEncodings := form.Value["fileEncoding"]
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
return nil, errs.ErrImportFileEncodingIsEmpty
}
fileEncoding := fileEncodings[0]
columnMappings := form.Value["columnMapping"]
if len(columnMappings) < 1 || columnMappings[0] == "" {
return nil, errs.ErrImportFileColumnMappingInvalid
}
var columnIndexMapping = map[datatable.TransactionDataTableColumn]int{}
err = json.Unmarshal([]byte(columnMappings[0]), &columnIndexMapping)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse column mapping for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrImportFileColumnMappingInvalid
}
transactionTypeMappings := form.Value["transactionTypeMapping"]
if len(transactionTypeMappings) < 1 || transactionTypeMappings[0] == "" {
return nil, errs.ErrImportFileTransactionTypeMappingInvalid
}
var transactionTypeNameMapping = map[string]models.TransactionType{}
err = json.Unmarshal([]byte(transactionTypeMappings[0]), &transactionTypeNameMapping)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse transaction type mapping for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrImportFileTransactionTypeMappingInvalid
}
hasHeaderLines := form.Value["hasHeaderLine"]
hasHeaderLine := false
if len(hasHeaderLines) > 0 {
hasHeaderLine = hasHeaderLines[0] == "true"
}
timeFormats := form.Value["timeFormat"]
if len(timeFormats) < 1 || timeFormats[0] == "" {
return nil, errs.ErrImportFileTransactionTimeFormatInvalid
}
timezoneFormats := form.Value["timezoneFormat"]
timezoneFormat := ""
if len(timezoneFormats) > 0 {
timezoneFormat = timezoneFormats[0]
}
amountDecimalSeparators := form.Value["amountDecimalSeparator"]
amountDecimalSeparator := ""
if len(amountDecimalSeparators) > 0 {
amountDecimalSeparator = amountDecimalSeparators[0]
}
amountDigitGroupingSymbols := form.Value["amountDigitGroupingSymbol"]
amountDigitGroupingSymbol := ""
if len(amountDigitGroupingSymbols) > 0 {
amountDigitGroupingSymbol = amountDigitGroupingSymbols[0]
}
geoLocationSeparators := form.Value["geoSeparator"]
geoLocationSeparator := ""
if len(geoLocationSeparators) > 0 {
geoLocationSeparator = geoLocationSeparators[0]
}
geoLocationOrders := form.Value["geoOrder"]
geoLocationOrder := ""
if len(geoLocationOrders) > 0 {
geoLocationOrder = geoLocationOrders[0]
}
transactionTagSeparators := form.Value["tagSeparator"]
transactionTagSeparator := ""
if len(transactionTagSeparators) > 0 {
transactionTagSeparator = transactionTagSeparators[0]
}
dataImporter, err = converters.CreateNewDelimiterSeparatedValuesDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
} else {
dataImporter, err = converters.GetTransactionDataImporter(fileType)
}
if err != nil {
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
@@ -1084,6 +1400,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
return nil, errs.ErrOperationFailed
}
defer importFile.Close()
fileData, err := io.ReadAll(importFile)
if err != nil {
@@ -1108,34 +1425,34 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
accounts, err := a.accounts.GetAllAccountsByUid(c, user.Uid)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get accounts for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get accounts for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountNameMapByList(accounts)
accountMap := a.accounts.GetVisibleAccountNameMapByList(accounts)
categories, err := a.transactionCategories.GetAllCategoriesByUid(c, user.Uid, 0, -1)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get categories for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get categories for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
expenseCategoryMap, incomeCategoryMap, transferCategoryMap := a.transactionCategories.GetCategoryNameMapByList(categories)
expenseCategoryMap, incomeCategoryMap, transferCategoryMap := a.transactionCategories.GetVisibleSubCategoryNameMapByList(categories)
tags, err := a.transactionTags.GetAllTagsByUid(c, user.Uid)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get tags for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get tags for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagMap := a.transactionTags.GetTagNameMapByList(tags)
tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
parsedTransactions, _, _, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, utcOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse imported data for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse imported data for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -1169,11 +1486,21 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId)
if found {
log.Infof(c, "[transactions.TransactionImportHandler] another \"%s\" transactions has been imported for user \"uid:%d\"", remark, uid)
count, err := utils.StringToInt(remark)
items := strings.Split(remark, ":")
if err == nil {
return count, nil
if len(items) >= 2 {
if items[0] == "finished" {
log.Infof(c, "[transactions.TransactionImportHandler] another \"%s\" transactions has been imported for user \"uid:%d\"", items[1], uid)
count, err := utils.StringToInt(items[1])
if err == nil {
return count, nil
}
} else if items[0] == "processing" {
return nil, errs.ErrRepeatedRequest
}
} else {
log.Warnf(c, "[transactions.TransactionImportHandler] another transaction import task may be executing, but remark \"%s\" is invalid", remark)
}
}
}
@@ -1189,7 +1516,7 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTagIdInvalid
}
if len(tagIds) > maximumTagsCountOfTransaction {
if len(tagIds) > models.MaximumTagsCountOfTransaction {
return nil, errs.ErrTransactionHasTooManyTags
}
@@ -1198,7 +1525,7 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTypeInvalid
}
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 {
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId != 0 {
log.Warnf(c, "[transactions.TransactionImportHandler] balance modification transaction \"index:%d\" cannot set category id", i)
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
}
@@ -1247,21 +1574,74 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
newTransactions[i] = transaction
}
err = a.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap)
err = a.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap, func(currentProcess float64) {
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId, fmt.Sprintf("processing:%.2f", currentProcess))
})
count := len(newTransactions)
if err != nil {
a.RemoveSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId)
log.Errorf(c, "[transactions.TransactionImportHandler] failed to import %d transactions for user \"uid:%d\", because %s", count, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionImportHandler] user \"uid:%d\" has imported %d transactions successfully", uid, count)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId, utils.IntToString(count))
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId, fmt.Sprintf("finished:%d", count))
return count, nil
}
// TransactionImportProcessHandler returns the process of specified transaction import task by request parameters for current user
func (a *TransactionsApi) TransactionImportProcessHandler(c *core.WebContext) (any, *errs.Error) {
var transactionImportProcessReq models.TransactionImportProcessRequest
err := c.ShouldBindQuery(&transactionImportProcessReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionImportProcessHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
if !a.CurrentConfig().EnableDuplicateSubmissionsCheck {
return nil, nil
}
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportProcessReq.ClientSessionId)
if !found {
return nil, nil
}
items := strings.Split(remark, ":")
if len(items) < 2 {
return nil, nil
}
if items[0] == "finished" {
return 100, nil
} else if items[0] != "processing" {
return nil, nil
}
process, err := utils.StringToFloat64(items[1])
if err != nil {
log.Warnf(c, "[transactions.TransactionImportProcessHandler] parse process failed, because %s", err.Error())
return nil, nil
}
if process < 0 {
return nil, nil
} else if process >= 100 {
process = 100
}
return process, nil
}
func (a *TransactionsApi) filterTransactions(c *core.WebContext, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account) []*models.Transaction {
finalTransactions := make([]*models.Transaction, 0, len(transactions))
@@ -1286,134 +1666,6 @@ func (a *TransactionsApi) filterTransactions(c *core.WebContext, uid int64, tran
return finalTransactions
}
func (a *TransactionsApi) getAccountOrSubAccountIds(c *core.WebContext, accountIds string, uid int64) ([]int64, error) {
if accountIds == "" || accountIds == "0" {
return nil, nil
}
requestAccountIds, err := utils.StringArrayToInt64Array(strings.Split(accountIds, ","))
if err != nil {
return nil, errs.Or(err, errs.ErrAccountIdInvalid)
}
var allAccountIds []int64
if len(requestAccountIds) > 0 {
allSubAccounts, err := a.accounts.GetSubAccountsByAccountIds(c, uid, requestAccountIds)
if err != nil {
return nil, err
}
accountIdsMap := make(map[int64]int32, len(requestAccountIds))
for i := 0; i < len(requestAccountIds); i++ {
accountIdsMap[requestAccountIds[i]] = 0
}
for i := 0; i < len(allSubAccounts); i++ {
subAccount := allSubAccounts[i]
if refCount, exists := accountIdsMap[subAccount.ParentAccountId]; exists {
accountIdsMap[subAccount.ParentAccountId] = refCount + 1
} else {
accountIdsMap[subAccount.ParentAccountId] = 1
}
if _, exists := accountIdsMap[subAccount.AccountId]; exists {
delete(accountIdsMap, subAccount.AccountId)
}
allAccountIds = append(allAccountIds, subAccount.AccountId)
}
for accountId, refCount := range accountIdsMap {
if refCount < 1 {
allAccountIds = append(allAccountIds, accountId)
}
}
}
return allAccountIds, nil
}
func (a *TransactionsApi) getCategoryOrSubCategoryIds(c *core.WebContext, categoryIds string, uid int64) ([]int64, error) {
if categoryIds == "" || categoryIds == "0" {
return nil, nil
}
requestCategoryIds, err := utils.StringArrayToInt64Array(strings.Split(categoryIds, ","))
if err != nil {
return nil, errs.Or(err, errs.ErrTransactionCategoryIdInvalid)
}
var allCategoryIds []int64
if len(requestCategoryIds) > 0 {
allSubCategories, err := a.transactionCategories.GetSubCategoriesByCategoryIds(c, uid, requestCategoryIds)
if err != nil {
return nil, err
}
categoryIdsMap := make(map[int64]int32, len(requestCategoryIds))
for i := 0; i < len(requestCategoryIds); i++ {
categoryIdsMap[requestCategoryIds[i]] = 0
}
for i := 0; i < len(allSubCategories); i++ {
subCategory := allSubCategories[i]
if refCount, exists := categoryIdsMap[subCategory.ParentCategoryId]; exists {
categoryIdsMap[subCategory.ParentCategoryId] = refCount + 1
} else {
categoryIdsMap[subCategory.ParentCategoryId] = 1
}
if _, exists := categoryIdsMap[subCategory.CategoryId]; exists {
delete(categoryIdsMap, subCategory.CategoryId)
}
allCategoryIds = append(allCategoryIds, subCategory.CategoryId)
}
for accountId, refCount := range categoryIdsMap {
if refCount < 1 {
allCategoryIds = append(allCategoryIds, accountId)
}
}
}
return allCategoryIds, nil
}
func (a *TransactionsApi) getTagIds(tagIds string) ([]int64, error) {
if tagIds == "" || tagIds == "0" {
return nil, nil
}
requestTagIds, err := utils.StringArrayToInt64Array(strings.Split(tagIds, ","))
if err != nil {
return nil, errs.Or(err, errs.ErrTransactionTagIdInvalid)
}
return requestTagIds, nil
}
func (a *TransactionsApi) getTransactionTagIds(allTransactionTagIds map[int64][]int64) []int64 {
allTagIds := make([]int64, 0, len(allTransactionTagIds))
for _, tagIds := range allTransactionTagIds {
allTagIds = append(allTagIds, tagIds...)
}
return allTagIds
}
func (a *TransactionsApi) getTransactionTagInfoResponses(tagIds []int64, allTransactionTags map[int64]*models.TransactionTag) []*models.TransactionTagInfoResponse {
allTags := make([]*models.TransactionTagInfoResponse, 0, len(tagIds))
@@ -1483,7 +1735,7 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
}
if !trimTag {
tagMap, err = a.transactionTags.GetTagsByTagIds(c, uid, utils.ToUniqueInt64Slice(a.getTransactionTagIds(allTransactionTagIds)))
tagMap, err = a.transactionTags.GetTagsByTagIds(c, uid, utils.ToUniqueInt64Slice(a.transactionTags.GetTransactionTagIds(allTransactionTagIds)))
if err != nil {
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions tags for user \"uid:%d\", because %s", uid, err.Error())
+235
View File
@@ -0,0 +1,235 @@
package api
import (
"encoding/json"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// UserApplicationCloudSettingsApi represents user application cloud settings api
type UserApplicationCloudSettingsApi struct {
userAppCloudSettings *services.UserApplicationCloudSettingsService
users *services.UserService
}
// Initialize a user application cloud settings api singleton instance
var (
UserApplicationCloudSettings = &UserApplicationCloudSettingsApi{
userAppCloudSettings: services.UserApplicationCloudSettings,
users: services.Users,
}
)
// ApplicationSettingsGetHandler returns application cloud settings of current user
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsGetHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
if err != nil {
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsGetHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if userApplicationCloudSettings == nil {
return false, nil
}
applicationCloudSettingSlice := userApplicationCloudSettings.Settings
if len(applicationCloudSettingSlice) < 1 {
return false, nil
}
return applicationCloudSettingSlice, nil
}
// ApplicationSettingsUpdateHandler updates user application cloud settings by request parameters for current user
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsUpdateHandler(c *core.WebContext) (any, *errs.Error) {
var userAppCloudSettingUpdateReq models.UserApplicationCloudSettingsUpdateRequest
err := c.ShouldBindJSON(&userAppCloudSettingUpdateReq)
if err != nil {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] parse request failed, because %s", err.Error())
return false, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return false, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS) {
return false, errs.ErrNotPermittedToPerformThisAction
}
var userApplicationCloudSettings *models.UserApplicationCloudSetting
// Retry up to 3 times
for i := 0; i < 3; i++ {
userApplicationCloudSettings, err = a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
if err != nil {
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\" (try count %d), because %s", uid, i+1, err.Error())
return false, errs.Or(err, errs.ErrOperationFailed)
}
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
lastUpdateTime := int64(0)
if userApplicationCloudSettings != nil {
for _, setting := range userApplicationCloudSettings.Settings {
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
}
lastUpdateTime = userApplicationCloudSettings.UpdatedUnixTime
}
// Check if the full update settings are the same as the existing settings
if userAppCloudSettingUpdateReq.FullUpdate {
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
needUpdate := false
for _, setting := range userAppCloudSettingUpdateReq.Settings {
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
if !exists || oldSetting.SettingValue != setting.SettingValue {
needUpdate = true
break
}
}
if !needUpdate {
return false, errs.ErrNothingWillBeUpdated
}
}
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
needUpdate := true
for _, setting := range userAppCloudSettingUpdateReq.Settings {
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
if !exists {
needUpdate = false
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync (try count %d)", setting.SettingKey, i+1)
} else if cloudSetting.SettingValue == setting.SettingValue {
needUpdate = false
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update (try count %d)", setting.SettingKey, setting.SettingValue, i+1)
}
}
if !needUpdate {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\" (try count %d)", uid, i+1)
return true, nil
}
}
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
if userAppCloudSettingUpdateReq.FullUpdate {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings (try count %d)", uid, i+1)
} else {
if len(oldApplicationCloudSettingsMap) > 0 {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings (try count %d)", uid, i+1)
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
}
}
for _, setting := range userAppCloudSettingUpdateReq.Settings {
newApplicationCloudSettingsMap[setting.SettingKey] = setting
}
for settingKey, setting := range newApplicationCloudSettingsMap {
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
if !exists {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync (try count %d)", settingKey, i+1)
continue
}
if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
// Do Nothing
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
_, err := utils.StringToFloat64(setting.SettingValue)
if err != nil {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
continue
}
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
if setting.SettingValue != "true" && setting.SettingValue != "false" {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
continue
}
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
var settingValueMap map[string]bool
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
if err != nil {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\" (try count %d), because %s", settingKey, setting.SettingValue, i+1, err.Error())
continue
}
} else {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\" (try count %d)", settingKey, settingType, i+1)
continue
}
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
}
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice, userAppCloudSettingUpdateReq.FullUpdate, lastUpdateTime)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond) // Wait for 100 milliseconds before retrying
}
if err != nil {
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
return false, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}
// ApplicationSettingsDisableHandler disabled user application cloud settings by request parameters for current user
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsDisableHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return false, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS) {
return false, errs.ErrNotPermittedToPerformThisAction
}
err = a.userAppCloudSettings.ClearUserApplicationCloudSettings(c, uid)
if err != nil {
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsDisableHandler] failed to clear user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
return false, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}
+59 -4
View File
@@ -78,6 +78,7 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
Language: userRegisterReq.Language,
DefaultCurrency: userRegisterReq.DefaultCurrency,
FirstDayOfWeek: userRegisterReq.FirstDayOfWeek,
FiscalYearStart: core.FISCAL_YEAR_START_DEFAULT,
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
}
@@ -349,6 +350,33 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
}
if userUpdateReq.FiscalYearStart != nil && *userUpdateReq.FiscalYearStart != user.FiscalYearStart {
user.FiscalYearStart = *userUpdateReq.FiscalYearStart
userNew.FiscalYearStart = *userUpdateReq.FiscalYearStart
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID
}
if userUpdateReq.CalendarDisplayType != nil && *userUpdateReq.CalendarDisplayType != user.CalendarDisplayType {
user.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
userNew.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.CalendarDisplayType = core.CALENDAR_DISPLAY_TYPE_INVALID
}
if userUpdateReq.DateDisplayType != nil && *userUpdateReq.DateDisplayType != user.DateDisplayType {
user.DateDisplayType = *userUpdateReq.DateDisplayType
userNew.DateDisplayType = *userUpdateReq.DateDisplayType
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.DateDisplayType = core.DATE_DISPLAY_TYPE_INVALID
}
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
user.LongDateFormat = *userUpdateReq.LongDateFormat
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
@@ -385,6 +413,33 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
}
if userUpdateReq.FiscalYearFormat != nil && *userUpdateReq.FiscalYearFormat != user.FiscalYearFormat {
user.FiscalYearFormat = *userUpdateReq.FiscalYearFormat
userNew.FiscalYearFormat = *userUpdateReq.FiscalYearFormat
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_INVALID
}
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
}
if userUpdateReq.NumeralSystem != nil && *userUpdateReq.NumeralSystem != user.NumeralSystem {
user.NumeralSystem = *userUpdateReq.NumeralSystem
userNew.NumeralSystem = *userUpdateReq.NumeralSystem
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.NumeralSystem = core.NUMERAL_SYSTEM_INVALID
}
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
@@ -412,13 +467,13 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
}
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
userNew.CoordinateDisplayType = core.COORDINATE_DISPLAY_TYPE_INVALID
}
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
+9 -5
View File
@@ -9,7 +9,7 @@ import (
// AvatarProviderContainer contains the current user avatar provider
type AvatarProviderContainer struct {
Current AvatarProvider
current AvatarProvider
}
// Initialize a user avatar provider container singleton instance
@@ -20,13 +20,13 @@ var (
// 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)
Container.current = NewInternalStorageAvatarProvider(config)
return nil
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
Container.Current = NewGravatarAvatarProvider()
Container.current = NewGravatarAvatarProvider()
return nil
} else if config.AvatarProvider == "" {
Container.Current = NewNullAvatarProvider()
Container.current = NewNullAvatarProvider()
return nil
}
@@ -35,5 +35,9 @@ func InitializeAvatarProvider(config *settings.Config) error {
// GetAvatarUrl returns the avatar url by the current user avatar provider
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
return p.Current.GetAvatarUrl(user)
if p.current == nil {
return ""
}
return p.current.GetAvatarUrl(user)
}
+1 -1
View File
@@ -9,5 +9,5 @@ type CliUsingConfig struct {
// CurrentConfig returns the current config
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
return l.container.Current
return l.container.GetCurrentConfig()
}
+59 -9
View File
@@ -394,7 +394,7 @@ func (l *UserDataCli) ListUserTokens(c *core.CliContext, username string) ([]*mo
return nil, err
}
tokens, err := l.tokens.GetAllUnexpiredNormalTokensByUid(c, uid)
tokens, err := l.tokens.GetAllUnexpiredNormalAndMCPTokensByUid(c, uid)
if err != nil {
log.CliErrorf(c, "[user_data.ListUserTokens] failed to get tokens of user \"%s\", because %s", username, err.Error())
@@ -405,7 +405,7 @@ func (l *UserDataCli) ListUserTokens(c *core.CliContext, username string) ([]*mo
}
// CreateNewUserToken returns a new token for the specified user
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string) (*models.TokenRecord, string, error) {
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, tokenType string) (*models.TokenRecord, string, error) {
if username == "" {
log.CliErrorf(c, "[user_data.CreateNewUserToken] user name is empty")
return nil, "", errs.ErrUsernameIsEmpty
@@ -418,7 +418,24 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string) (*
return nil, "", err
}
token, tokenRecord, err := l.tokens.CreateTokenViaCli(c, user)
var token string
var tokenRecord *models.TokenRecord
if tokenType == "mcp" {
if !l.CurrentConfig().EnableMCPServer {
return nil, "", errs.ErrMCPServerNotEnabled
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
return nil, "", errs.ErrNotPermittedToPerformThisAction
}
token, tokenRecord, err = l.tokens.CreateMCPTokenViaCli(c, user)
} else if tokenType == "normal" {
token, tokenRecord, err = l.tokens.CreateTokenViaCli(c, user)
} else {
return nil, "", errs.ErrParameterInvalid
}
if err != nil {
log.CliErrorf(c, "[user_data.CreateNewUserToken] failed to create token for user \"%s\", because %s", username, err.Error())
@@ -428,6 +445,39 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string) (*
return tokenRecord, token, nil
}
// RevokeUserToken revokes the specified token of the user
func (l *UserDataCli) RevokeUserToken(c *core.CliContext, token string) error {
_, claims, err := l.tokens.ParseToken(c, token)
if err != nil {
log.CliErrorf(c, "[user_data.RevokeUserToken] failed to parse token, because %s", err.Error())
return err
}
userTokenId, err := utils.StringToInt64(claims.UserTokenId)
if err != nil {
log.CliErrorf(c, "[user_data.RevokeUserToken] failed to get user token id, because %s", err.Error())
return err
}
tokenRecord := &models.TokenRecord{
Uid: claims.Uid,
UserTokenId: userTokenId,
CreatedUnixTime: claims.IssuedAt,
}
tokenId := l.tokens.GenerateTokenId(tokenRecord)
err = l.tokens.DeleteToken(c, tokenRecord)
if err != nil {
log.Errorf(c, "[user_data.RevokeUserToken] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
return err
}
return nil
}
// ClearUserTokens clears all tokens of the specified user
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
if username == "" {
@@ -810,7 +860,7 @@ func (l *UserDataCli) ImportTransaction(c *core.CliContext, username string, fil
return errs.ErrOperationFailed
}
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap)
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap, nil)
if err != nil {
log.CliErrorf(c, "[user_data.ImportTransaction] failed to create transaction, because %s", err.Error())
@@ -876,7 +926,7 @@ func (l *UserDataCli) getUserEssentialData(c *core.CliContext, uid int64, userna
return accountMap, categoryMap, tagMap, tagIndexes, tagIndexesMap, nil
}
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) {
func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int64, username string) (accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]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
@@ -889,7 +939,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
return nil, nil, nil, nil, nil, err
}
accountMap = l.accounts.GetAccountNameMapByList(accounts)
accountMap = l.accounts.GetVisibleAccountNameMapByList(accounts)
categories, err := l.categories.GetAllCategoriesByUid(c, uid, 0, -1)
@@ -898,7 +948,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
return nil, nil, nil, nil, nil, err
}
expenseCategoryMap, incomeCategoryMap, transferCategoryMap = l.categories.GetCategoryNameMapByList(categories)
expenseCategoryMap, incomeCategoryMap, transferCategoryMap = l.categories.GetVisibleSubCategoryNameMapByList(categories)
tags, err := l.tags.GetAllTagsByUid(c, uid)
@@ -907,7 +957,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
return nil, nil, nil, nil, nil, err
}
tagMap = l.tags.GetTagNameMapByList(tags)
tagMap = l.tags.GetVisibleTagNameMapByList(tags)
return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil
}
@@ -959,7 +1009,7 @@ func (l *UserDataCli) checkTransactionCategory(c *core.CliContext, transaction *
return errs.ErrTransactionCategoryNotFound
}
if category.ParentCategoryId == models.LevelOneTransactionParentId {
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
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
}
@@ -2,20 +2,17 @@ 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/converter"
"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{
@@ -56,17 +53,23 @@ type alipayTransactionDataCsvFileImporter struct {
}
// 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) {
func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]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)
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
commonDataTable := datatable.CreateNewImportedCommonDataTable(dataTable)
dataTable, err := createNewAlipayTransactionBasicDataTable(ctx, csvDataTable, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
if !commonDataTable.HasColumn(c.originalColumnNames.timeColumnName) ||
!commonDataTable.HasColumn(c.originalColumnNames.amountColumnName) ||
@@ -76,86 +79,9 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
transactionRowParser := createAlipayTransactionDataRowParser(c.originalColumnNames)
transactionDataTable := datatable.CreateNewCommonTransactionDataTable(commonDataTable, alipayTransactionSupportedColumns, transactionRowParser)
dataTableImporter := datatable.CreateNewSimpleImporter(alipayTransactionTypeNameMapping)
transactionRowParser := createAlipayTransactionDataRowParser(c.originalColumnNames, dataTable.HeaderColumnNames())
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, alipayTransactionSupportedColumns, transactionRowParser)
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(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
}
@@ -102,6 +102,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
DefaultCurrency: "CNY",
}
// refund
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
@@ -121,6 +122,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
// tax refund
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
@@ -141,6 +143,46 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvestmentRefundTransaction(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:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,退款成功,\n" +
"2024-09-01 02:00:00,Test Account2,xxx-买入退款,不计收支,0.01,Test Account,退款成功,\n")
allNewTransactions, _, _, _, _, _, 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, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 01:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
assert.Equal(t, "2024-09-01 02:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalDestinationAccountName)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
@@ -380,38 +422,110 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T
"起始时间:[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")
"交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,备注,\n" +
"2024-09-01 00:00:00,xxx,xxx-收益发放,不计收支,0.01,Test Account,交易成功,earning,\n" +
"2024-09-01 01:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,交易成功,purchase investment,\n" +
"2024-09-01 02:00:00,Test Account2,xxx-卖出至xxx,不计收支,0.01,Test Account,交易成功,sell investment,\n" +
"2024-09-01 03:00:00,xxx,充值-普通充值,不计收支,0.01,Test Account,交易成功,transfer to alipay wallet,\n" +
"2024-09-01 04:00:00,Test Account3,提现-实时提现,不计收支,0.01,Test Account,交易成功,transfer from alipay wallet,\n" +
"2024-09-01 05:00:00,Test Account3,xxx-单次转入,不计收支,0.01,Test Account,交易成功,transfer in,\n" +
"2024-09-01 06:00:00,Test Account3,xxx-转出到银行卡,不计收支,0.01,Test Account,交易成功,transfer out,\n" +
"2024-09-01 07:00:00,Test Account3,转账xxx,不计收支,0.01,Test Account,交易成功,transfer,\n" +
"2024-09-01 08:00:00,Test Account4,信用卡还款,不计收支,0.01,Test Account,还款成功,repayment,\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, 9, len(allNewTransactions))
assert.Equal(t, 6, len(allNewAccounts))
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
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, "earning", allNewTransactions[0].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
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(1), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalDestinationAccountName)
assert.Equal(t, "purchase investment", allNewTransactions[1].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, int64(1), allNewTransactions[2].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalDestinationAccountName)
assert.Equal(t, "sell investment", allNewTransactions[2].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, int64(1), allNewTransactions[3].Amount)
assert.Equal(t, "", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Alipay", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "transfer to alipay wallet", allNewTransactions[3].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
assert.Equal(t, int64(1), allNewTransactions[4].Amount)
assert.Equal(t, "Alipay", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "transfer from alipay wallet", allNewTransactions[4].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
assert.Equal(t, int64(1), allNewTransactions[5].Amount)
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[5].OriginalDestinationAccountName)
assert.Equal(t, "transfer in", allNewTransactions[5].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[6].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
assert.Equal(t, int64(1), allNewTransactions[6].Amount)
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[6].OriginalDestinationAccountName)
assert.Equal(t, "transfer out", allNewTransactions[6].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[7].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[7].Uid)
assert.Equal(t, int64(1), allNewTransactions[7].Amount)
assert.Equal(t, "Test Account", allNewTransactions[7].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[7].OriginalDestinationAccountName)
assert.Equal(t, "transfer", allNewTransactions[7].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[8].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[8].Uid)
assert.Equal(t, int64(1), allNewTransactions[8].Amount)
assert.Equal(t, "Test Account", allNewTransactions[8].OriginalSourceAccountName)
assert.Equal(t, "Test Account4", allNewTransactions[8].OriginalDestinationAccountName)
assert.Equal(t, "repayment", allNewTransactions[8].Comment)
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, "Test Account2", 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, "", allNewAccounts[2].Name)
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[3].Uid)
assert.Equal(t, "Alipay", allNewAccounts[3].Name)
assert.Equal(t, "CNY", allNewAccounts[3].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[4].Uid)
assert.Equal(t, "Test Account3", allNewAccounts[4].Name)
assert.Equal(t, "CNY", allNewAccounts[4].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[5].Uid)
assert.Equal(t, "Test Account4", allNewAccounts[5].Name)
assert.Equal(t, "CNY", allNewAccounts[5].Currency)
}
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
@@ -0,0 +1,78 @@
package alipay
import (
"strings"
"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/utils"
)
func createNewAlipayTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.BasicDataTable, error) {
iterator := originalDataTable.DataRowIterator()
allOriginalLines := make([][]string, 0)
hasFileHeader := false
foundContentBeforeDataHeaderLine := false
for iterator.HasNext() {
row := iterator.Next()
if !hasFileHeader {
if row.ColumnCount() <= 0 {
continue
} else if strings.Index(row.GetData(0), fileHeaderLine) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
continue
}
}
if !foundContentBeforeDataHeaderLine {
if row.ColumnCount() <= 0 {
continue
} else if strings.Index(row.GetData(0), dataHeaderStartContent) >= 0 {
foundContentBeforeDataHeaderLine = true
continue
} else {
continue
}
}
if foundContentBeforeDataHeaderLine {
if row.ColumnCount() <= 0 {
continue
} else if row.ColumnCount() == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(row.GetData(0), dataBottomEndLineRune) {
break
}
items := make([]string, row.ColumnCount())
for i := 0; i < row.ColumnCount(); i++ {
items[i] = strings.Trim(row.GetData(i), " ")
}
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
log.Errorf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), 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_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
}
@@ -18,19 +18,25 @@ const alipayTransactionDataStatusClosedName = "交易关闭"
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
const alipayTransactionDataProductNameEarningText = "-收益发放"
const alipayTransactionDataProductNamePurchaseInvestmentText = "-买入"
const alipayTransactionDataProductNamePurchaseInvestmentRefundText = "-买入退款"
const alipayTransactionDataProductNameSellInvestmentRefundText = "-卖出"
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
const alipayTransactionDataProductNameTransferInText = "转入"
const alipayTransactionDataProductNameTransferOutText = "转出"
const alipayTransactionDataProductNameTransferText = "转账"
const alipayTransactionDataProductNameRepaymentText = "还款"
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
type alipayTransactionDataRowParser struct {
columns alipayTransactionColumnNames
columns alipayTransactionColumnNames
existedOriginalDataColumns map[string]bool
}
// 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) {
func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow datatable.CommonDataTableRow, 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] {
@@ -50,23 +56,23 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
data := make(map[datatable.TransactionDataTableColumn]string, len(alipayTransactionSupportedColumns))
if dataTable.HasOriginalColumn(p.columns.timeColumnName) {
if p.hasOriginalColumn(p.columns.timeColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData(p.columns.timeColumnName)
}
if dataTable.HasOriginalColumn(p.columns.categoryColumnName) {
if p.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) {
if p.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) != "" {
if p.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) != "" {
} else if p.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] = ""
@@ -74,13 +80,13 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
relatedAccountName := ""
if dataTable.HasOriginalColumn(p.columns.relatedAccountColumnName) {
if p.hasOriginalColumn(p.columns.relatedAccountColumnName) {
relatedAccountName = dataRow.GetData(p.columns.relatedAccountColumnName)
}
statusName := ""
if dataTable.HasOriginalColumn(p.columns.statusColumnName) {
if p.hasOriginalColumn(p.columns.statusColumnName) {
statusName = dataRow.GetData(p.columns.statusColumnName)
}
@@ -92,7 +98,7 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
localeTextItems := locales.GetLocaleTextItems(locale)
if dataTable.HasOriginalColumn(p.columns.typeColumnName) {
if p.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] {
@@ -117,20 +123,38 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
targetName := ""
productName := ""
if dataTable.HasOriginalColumn(p.columns.targetNameColumnName) {
if p.hasOriginalColumn(p.columns.targetNameColumnName) {
targetName = dataRow.GetData(p.columns.targetNameColumnName)
}
if dataTable.HasOriginalColumn(p.columns.productNameColumnName) {
if p.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] = ""
if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentText) { // purchase investment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentRefundText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentRefundText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentRefundText) { // purchase investment refund
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = targetName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
} else {
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
if len(productName) > len(alipayTransactionDataProductNameEarningText) && strings.Index(productName, alipayTransactionDataProductNameEarningText) == len(productName)-len(alipayTransactionDataProductNameEarningText) { // earning
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] = targetName
} else if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentText) { // purchase investment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameSellInvestmentRefundText) >= 0 { // sell investment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = targetName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
} 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
@@ -142,6 +166,9 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
} 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, alipayTransactionDataProductNameTransferText) >= 0 { // transfer
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
@@ -170,9 +197,21 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
return data, true, nil
}
func (p *alipayTransactionDataRowParser) hasOriginalColumn(columnName string) bool {
_, exists := p.existedOriginalDataColumns[columnName]
return exists
}
// createAlipayTransactionDataRowParser returns alipay transaction data row parser
func createAlipayTransactionDataRowParser(originalColumnNames alipayTransactionColumnNames) datatable.CommonTransactionDataRowParser {
func createAlipayTransactionDataRowParser(originalColumnNames alipayTransactionColumnNames, headerColumnNames []string) datatable.CommonTransactionDataRowParser {
existedOriginalDataColumns := make(map[string]bool, len(headerColumnNames))
for i := 0; i < len(headerColumnNames); i++ {
existedOriginalDataColumns[headerColumnNames[i]] = true
}
return &alipayTransactionDataRowParser{
columns: originalColumnNames,
columns: originalColumnNames,
existedOriginalDataColumns: existedOriginalDataColumns,
}
}
@@ -0,0 +1,242 @@
package beancount
import (
"math/big"
"strings"
"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 maxAllowedDecimalCount = 6
const normalizeFactor = int64(1000000)
const normalizedDecimalsMaxZeroString = "000000"
const normalizedNumberToAmountFactor = int64(10000) // 1000000 / 100
var operatorPriority = map[rune]int{
'+': 1,
'-': 1,
'*': 2,
'/': 2,
}
func normalizeNumber(textualNumber string) (*big.Int, error) {
decimalSeparatorPos := strings.Index(textualNumber, ".")
if decimalSeparatorPos < 0 {
result := big.NewInt(0)
_, ok := result.SetString(textualNumber+normalizedDecimalsMaxZeroString, 10)
if !ok {
return nil, errs.ErrAmountInvalid
}
return result, nil
}
integer := utils.SubString(textualNumber, 0, decimalSeparatorPos)
decimals := utils.SubString(textualNumber, decimalSeparatorPos+1, len(textualNumber))
if len(decimals) > maxAllowedDecimalCount {
return nil, errs.ErrAmountInvalid
}
paddedDecimals := utils.SubString(decimals+normalizedDecimalsMaxZeroString, 0, maxAllowedDecimalCount)
result := big.NewInt(0)
_, ok := result.SetString(integer+paddedDecimals, 10)
if !ok {
return nil, errs.ErrAmountInvalid
}
return result, nil
}
func denormalizeNumberToTextualAmount(num *big.Int) string {
result := big.NewInt(0).Add(num, big.NewInt(0)) // make a copy of num
result = result.Div(result, big.NewInt(normalizedNumberToAmountFactor))
return utils.FormatAmount(result.Int64())
}
func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
finalTokens := make([]string, 0)
operatorStack := make([]rune, 0)
currentNumberBuilder := strings.Builder{}
isLastTokenOperator := true
expr = strings.ReplaceAll(expr, " ", "")
for i := 0; i < len(expr); i++ {
ch := rune(expr[i])
// number
if '0' <= ch && ch <= '9' || ch == '.' {
currentNumberBuilder.WriteRune(ch)
continue
} else if ch == '-' && i+1 < len(expr) && '0' <= expr[i+1] && expr[i+1] <= '9' && currentNumberBuilder.Len() == 0 && isLastTokenOperator {
currentNumberBuilder.WriteRune(ch)
continue
}
// operator or parenthesis
if currentNumberBuilder.Len() > 0 {
finalTokens = append(finalTokens, currentNumberBuilder.String())
currentNumberBuilder.Reset()
isLastTokenOperator = false
}
switch ch {
case '+', '-', '*', '/':
if ch == '-' && isLastTokenOperator {
currentNumberBuilder.WriteRune(ch)
continue
}
for len(operatorStack) > 0 {
topOperator := operatorStack[len(operatorStack)-1]
if topOperator == '(' {
break
}
if operatorPriority[topOperator] >= operatorPriority[ch] {
finalTokens = append(finalTokens, string(topOperator))
operatorStack = operatorStack[:len(operatorStack)-1]
} else {
break
}
}
operatorStack = append(operatorStack, ch)
isLastTokenOperator = true
case '(':
operatorStack = append(operatorStack, ch)
isLastTokenOperator = true
case ')':
hasLeftParenthesis := false
for len(operatorStack) > 0 {
topOperator := operatorStack[len(operatorStack)-1]
operatorStack = operatorStack[:len(operatorStack)-1]
if topOperator == '(' {
hasLeftParenthesis = true
break
}
finalTokens = append(finalTokens, string(topOperator))
}
if !hasLeftParenthesis {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because missing left parenthesis", expr)
return nil, errs.ErrInvalidAmountExpression
}
isLastTokenOperator = false
default:
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because containing unknown token \"%c\"", expr, ch)
return nil, errs.ErrInvalidAmountExpression
}
}
if currentNumberBuilder.Len() > 0 {
finalTokens = append(finalTokens, currentNumberBuilder.String())
}
for len(operatorStack) > 0 {
topOperator := operatorStack[len(operatorStack)-1]
operatorStack = operatorStack[:len(operatorStack)-1]
if topOperator == '(' {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because missing right parenthesis", expr)
return nil, errs.ErrInvalidAmountExpression
}
finalTokens = append(finalTokens, string(topOperator))
}
return finalTokens, nil
}
func evaluatePostfixExpr(ctx core.Context, tokens []string) (*big.Int, error) {
stack := make([]*big.Int, 0)
for i := 0; i < len(tokens); i++ {
token := tokens[i]
switch token {
case "+", "-", "*", "/": // operators
if len(stack) < 2 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because not enough operands", strings.Join(tokens, " "))
return nil, errs.ErrInvalidAmountExpression
}
// pop the top two operands
b := stack[len(stack)-1]
stack = stack[:len(stack)-1]
a := stack[len(stack)-1]
stack = stack[:len(stack)-1]
// evaluate the operation
result := big.NewInt(0)
switch token {
case "+":
result.Add(a, b)
case "-":
result.Sub(a, b)
case "*":
result.Mul(a, b)
result.Div(result, big.NewInt(normalizeFactor))
case "/":
if b.Int64() == 0 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " "))
return nil, errs.ErrInvalidAmountExpression
}
result.Mul(a, big.NewInt(normalizeFactor))
result.Div(result, b)
}
// push the result back to the stack
stack = append(stack, result)
default: // operands
normalizedNum, err := normalizeNumber(token)
if err != nil {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because containing invalid number", strings.Join(tokens, " "))
return nil, errs.ErrInvalidAmountExpression
}
stack = append(stack, normalizedNum)
}
}
if len(stack) != 1 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because missing operator", strings.Join(tokens, " "))
return nil, errs.ErrInvalidAmountExpression
}
return stack[0], nil
}
func evaluateBeancountAmountExpression(ctx core.Context, expr string) (string, error) {
if expr == "" {
return "", nil
}
postfixExprTokens, err := toPostfixExprTokens(ctx, expr)
if err != nil {
return "", err
}
result, err := evaluatePostfixExpr(ctx, postfixExprTokens)
if err != nil {
return "", err
}
return denormalizeNumberToTextualAmount(result), nil
}
@@ -0,0 +1,235 @@
package beancount
import (
"math/big"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestToPostfixExprTokens_ValidExpression(t *testing.T) {
context := core.NewNullContext()
result, err := toPostfixExprTokens(context, "1+2")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "+"}, result)
result, err = toPostfixExprTokens(context, "3-4")
assert.Nil(t, err)
assert.Equal(t, []string{"3", "4", "-"}, result)
result, err = toPostfixExprTokens(context, "5*6")
assert.Nil(t, err)
assert.Equal(t, []string{"5", "6", "*"}, result)
result, err = toPostfixExprTokens(context, "8/2")
assert.Nil(t, err)
assert.Equal(t, []string{"8", "2", "/"}, result)
result, err = toPostfixExprTokens(context, "1+2*3-(4/2)")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"}, result)
result, err = toPostfixExprTokens(context, "1 + 2 * 3")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "3", "*", "+"}, result)
result, err = toPostfixExprTokens(context, "-1+2")
assert.Nil(t, err)
assert.Equal(t, []string{"-1", "2", "+"}, result)
result, err = toPostfixExprTokens(context, "1.5+2.3")
assert.Nil(t, err)
assert.Equal(t, []string{"1.5", "2.3", "+"}, result)
result, err = toPostfixExprTokens(context, "(1+2)-3")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "+", "3", "-"}, result)
result, err = toPostfixExprTokens(context, "2*-3-3/-2")
assert.Nil(t, err)
assert.Equal(t, []string{"2", "-3", "*", "3", "-2", "/", "-"}, result)
result, err = toPostfixExprTokens(context, "-1.2-3.4*(-5.6/7.8*(9.0-1.2))")
assert.Nil(t, err)
assert.Equal(t, []string{"-1.2", "3.4", "-5.6", "7.8", "/", "9.0", "1.2", "-", "*", "*", "-"}, result)
result, err = toPostfixExprTokens(context, "((((((1+2)*(3+4))))))")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "+", "3", "4", "+", "*"}, result)
result, err = toPostfixExprTokens(context, "(((())))")
assert.Nil(t, err)
assert.Equal(t, []string{}, result)
result, err = toPostfixExprTokens(context, "+-*/")
assert.Nil(t, err)
assert.Equal(t, []string{"-", "*", "/", "+"}, result)
result, err = toPostfixExprTokens(context, "")
assert.Nil(t, err)
assert.Equal(t, []string{}, result)
}
func TestToPostfixExprTokens_InvalidExpression(t *testing.T) {
context := core.NewNullContext()
_, err := toPostfixExprTokens(context, "1=2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = toPostfixExprTokens(context, "(1")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = toPostfixExprTokens(context, "2)")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = toPostfixExprTokens(context, "((((1+2)))")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = toPostfixExprTokens(context, ")(")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
}
func TestEvaluatePostfixExpr_ValidExpression(t *testing.T) {
context := core.NewNullContext()
result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"})
assert.Nil(t, err)
assert.Equal(t, big.NewInt(3000000), result)
result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"})
assert.Nil(t, err)
assert.Equal(t, big.NewInt(2000000), result)
result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"})
assert.Nil(t, err)
assert.Equal(t, big.NewInt(12000000), result)
result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"})
assert.Nil(t, err)
assert.Equal(t, big.NewInt(3000000), result)
result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"})
assert.Nil(t, err)
assert.Equal(t, big.NewInt(5000000), result)
}
func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) {
context := core.NewNullContext()
_, err := evaluatePostfixExpr(context, []string{"1", "0", "/"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", "+"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", "="})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", "("})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", ")"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", "2", "+", "3"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"abc"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
}
func TestEvaluateBeancountAmountExpression_ValidExpression(t *testing.T) {
context := core.NewNullContext()
result, err := evaluateBeancountAmountExpression(context, "")
assert.Nil(t, err)
assert.Equal(t, "", result)
result, err = evaluateBeancountAmountExpression(context, "1+2")
assert.Nil(t, err)
assert.Equal(t, "3.00", result)
result, err = evaluateBeancountAmountExpression(context, "(1+2)*3")
assert.Nil(t, err)
assert.Equal(t, "9.00", result)
result, err = evaluateBeancountAmountExpression(context, "-1+2")
assert.Nil(t, err)
assert.Equal(t, "1.00", result)
result, err = evaluateBeancountAmountExpression(context, "1.5+2.5")
assert.Nil(t, err)
assert.Equal(t, "4.00", result)
result, err = evaluateBeancountAmountExpression(context, "1+2*3-(4/2)")
assert.Nil(t, err)
assert.Equal(t, "5.00", result)
result, err = evaluateBeancountAmountExpression(context, "2*-3-3/-2")
assert.Nil(t, err)
assert.Equal(t, "-4.50", result)
result, err = evaluateBeancountAmountExpression(context, "-1.2-3.4*(-5.6/7.8*(9.0-1.2))")
assert.Nil(t, err)
assert.Equal(t, "17.84", result)
result, err = evaluateBeancountAmountExpression(context, "(((2+3)))*(((((-5+7)))))")
assert.Nil(t, err)
assert.Equal(t, "10.00", result)
result, err = evaluateBeancountAmountExpression(context, "3.5+0.1")
assert.Nil(t, err)
assert.Equal(t, "3.60", result)
result, err = evaluateBeancountAmountExpression(context, "3.55+0.11")
assert.Nil(t, err)
assert.Equal(t, "3.66", result)
result, err = evaluateBeancountAmountExpression(context, "3.555+0.111")
assert.Nil(t, err)
assert.Equal(t, "3.66", result)
}
func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
context := core.NewNullContext()
_, err := evaluateBeancountAmountExpression(context, "1++2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1^2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "+-*/")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "a+b")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1/0")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1+(2*3")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1+2*3)")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1+((((2*3)))")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1+2(3)")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1)*(2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "0.abcd+1")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "0.1234567+1")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
}
@@ -0,0 +1,93 @@
package beancount
import "strings"
const beancountEquityAccountNameOpeningBalance = "Opening-Balances"
// beancountDirective represents the Beancount directive
type beancountDirective string
// Beancount directives
const (
beancountDirectiveOpen beancountDirective = "open"
beancountDirectiveClose beancountDirective = "close"
beancountDirectiveTransaction beancountDirective = "txn"
beancountDirectiveCompletedTransaction beancountDirective = "*"
beancountDirectiveInCompleteTransaction beancountDirective = "!"
beancountDirectivePaddingTransaction beancountDirective = "P"
beancountDirectiveCommodity beancountDirective = "commodity"
beancountDirectivePrice beancountDirective = "price"
beancountDirectiveNote beancountDirective = "note"
beancountDirectiveDocument beancountDirective = "document"
beancountDirectiveEvent beancountDirective = "event"
beancountDirectiveBalance beancountDirective = "balance"
beancountDirectivePad beancountDirective = "pad"
beancountDirectiveQuery beancountDirective = "query"
beancountDirectiveCustom beancountDirective = "custom"
)
// beancountAccountType represents the Beancount account type
type beancountAccountType byte
// Beancount account types
const (
beancountUnknownAccountType beancountAccountType = 0
beancountAssetsAccountType beancountAccountType = 1
beancountLiabilitiesAccountType beancountAccountType = 2
beancountEquityAccountType beancountAccountType = 3
beancountIncomeAccountType beancountAccountType = 4
beancountExpensesAccountType beancountAccountType = 5
)
// beancountData defines the structure of beancount data
type beancountData struct {
Accounts map[string]*beancountAccount
Transactions []*beancountTransactionEntry
}
// beancountAccount defines the structure of beancount account
type beancountAccount struct {
Name string
AccountType beancountAccountType
OpenDate string
CloseDate string
}
// beancountTransactionEntry defines the structure of beancount transaction entry
type beancountTransactionEntry struct {
Date string
Directive beancountDirective
Payee string
Narration string
Postings []*beancountPosting
Tags []string
Links []string
Metadata map[string]string
}
// beancountPosting defines the structure of beancount transaction posting
type beancountPosting struct {
Account string
Amount string
OriginalAmount string
Commodity string
TotalCost string
TotalCostCommodity string
Price string
PriceCommodity string
Metadata map[string]string
}
func (a *beancountAccount) isOpeningBalanceEquityAccount() bool {
if a.AccountType != beancountEquityAccountType {
return false
}
nameItems := strings.Split(a.Name, string(beancountMetadataKeySuffix))
if len(nameItems) != 2 {
return false
}
return nameItems[1] == beancountEquityAccountNameOpeningBalance
}
@@ -0,0 +1,655 @@
package beancount
import (
"bytes"
"encoding/csv"
"io"
"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"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const beancountDefaultAssetsAccountTypeName = "Assets"
const beancountDefaultLiabilitiesAccountTypeName = "Liabilities"
const beancountDefaultEquityAccountTypeName = "Equity"
const beancountDefaultIncomeAccountTypeName = "Income"
const beancountDefaultExpenseAccountTypeName = "Expenses"
const beancountOptionAssetsAccountTypeName = "name_assets"
const beancountOptionLiabilitiesAccountTypeName = "name_liabilities"
const beancountOptionEquityAccountTypeName = "name_equity"
const beancountOptionIncomeAccountTypeName = "name_income"
const beancountOptionExpenseAccountTypeName = "name_expenses"
const beancountCommentPrefix = ';'
const beancountAccountNameItemsSeparator = ":"
const beancountMetadataKeySuffix = ':'
const beancountPricePrefix = '@'
const beancountLinkPrefix = '^'
const beancountTagPrefix = '#'
// beancountDataReader defines the structure of Beancount data reader
type beancountDataReader struct {
accountTypeNameMap map[string]beancountAccountType
accountTypeNameReversedMap map[beancountAccountType]string
allData [][]string
}
// read returns the imported Beancount data
// Reference: https://beancount.github.io/docs/beancount_language_syntax.html
func (r *beancountDataReader) read(ctx core.Context) (*beancountData, error) {
if len(r.allData) < 1 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
data := &beancountData{
Accounts: make(map[string]*beancountAccount),
Transactions: make([]*beancountTransactionEntry, 0),
}
var err error
var currentTransactionEntry *beancountTransactionEntry
var currentTransactionPosting *beancountPosting
var currentTags []string
for i := 0; i < len(r.allData); i++ {
items := r.allData[i]
if len(items) == 0 || (len(items) == 1 && len(items[0]) == 0) || (len(r.getNotEmptyItemByIndex(items, 0)) > 0 && r.getNotEmptyItemByIndex(items, 0)[0] == beancountCommentPrefix) { // skip empty or comment lines
continue
}
if r.getNotEmptyItemsCount(items) < 2 {
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because not enough items in line", i, strings.Join(items, " "))
continue
}
firstItem := items[0]
if firstItem == "include" { // not support include directive
return nil, errs.ErrBeancountFileNotSupportInclude
} else if firstItem == "plugin" { // skip plugin directive lines
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
continue
} else if firstItem == "option" {
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
r.readAndSetOption(ctx, i, items)
continue
} else if firstItem == "pushtag" {
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
currentTags = r.readAndSetTags(ctx, i, items, currentTags, true)
continue
} else if firstItem == "poptag" {
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
currentTags = r.readAndSetTags(ctx, i, items, currentTags, false)
continue
}
if len(firstItem) == 0 { // original line has space prefix, maybe transaction posting or metadata line
actualFirstItem := r.getNotEmptyItemByIndex(items, 0)
if len(actualFirstItem) == 0 { // skip empty lines
continue
}
if ('A' <= actualFirstItem[0] && actualFirstItem[0] <= 'Z') || actualFirstItem[0] == '!' { // transaction posting
if currentTransactionEntry != nil && currentTransactionPosting != nil {
currentTransactionEntry.Postings = append(currentTransactionEntry.Postings, currentTransactionPosting)
currentTransactionPosting = nil
}
currentTransactionPosting, err = r.readTransactionPostingLine(ctx, i, items, data, actualFirstItem[0] == '!')
if err != nil {
return nil, err
}
} else if 'a' <= actualFirstItem[0] && actualFirstItem[0] <= 'z' { // metadata
metadata := r.readTransactionMetadataLine(ctx, i, items)
if metadata == nil {
continue
}
metadataKey := metadata[0]
metadataValue := metadata[1]
if currentTransactionPosting != nil {
if _, exists := currentTransactionPosting.Metadata[metadataKey]; !exists {
currentTransactionPosting.Metadata[metadataKey] = metadataValue
}
} else if currentTransactionEntry != nil {
if _, exists := currentTransactionEntry.Metadata[metadataKey]; !exists {
currentTransactionEntry.Metadata[metadataKey] = metadataValue
}
}
} else {
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because line prefix is invalid", i, strings.Join(items, " "))
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
continue
}
} else if _, err := utils.ParseFromLongDateFirstTime(firstItem, 0); err == nil { // original line has date as first item
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
directive := r.getNotEmptyItemByIndex(items, 1)
if directive == string(beancountDirectiveOpen) ||
directive == string(beancountDirectiveClose) {
_, err := r.readAccountLine(ctx, i, items, firstItem, beancountDirective(directive), data)
if err != nil {
return nil, err
}
} else if directive == string(beancountDirectiveTransaction) ||
directive == string(beancountDirectiveCompletedTransaction) ||
directive == string(beancountDirectiveInCompleteTransaction) ||
directive == string(beancountDirectivePaddingTransaction) {
currentTransactionEntry = r.readTransactionLine(ctx, i, items, firstItem, beancountDirective(directive), currentTags)
} else if directive == string(beancountDirectiveCommodity) ||
directive == string(beancountDirectivePrice) ||
directive == string(beancountDirectiveNote) ||
directive == string(beancountDirectiveDocument) ||
directive == string(beancountDirectiveEvent) ||
directive == string(beancountDirectiveBalance) ||
directive == string(beancountDirectivePad) ||
directive == string(beancountDirectiveQuery) ||
directive == string(beancountDirectiveCustom) { // skip commodity / price / note / document / event / balance / pad / query / custom lines
continue
} else {
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because directive is unknown", i, strings.Join(items, " "))
continue
}
} else { // first item not start with date or space
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
continue
}
}
if currentTransactionEntry != nil {
if currentTransactionPosting != nil {
currentTransactionEntry.Postings = append(currentTransactionEntry.Postings, currentTransactionPosting)
currentTransactionPosting = nil
}
data.Transactions = append(data.Transactions, currentTransactionEntry)
currentTransactionEntry = nil
}
return data, nil
}
func (r *beancountDataReader) updateCurrentState(data *beancountData, currentTransactionEntry *beancountTransactionEntry, currentTransactionPosting *beancountPosting) (*beancountTransactionEntry, *beancountPosting) {
if currentTransactionEntry != nil {
if currentTransactionPosting != nil {
currentTransactionEntry.Postings = append(currentTransactionEntry.Postings, currentTransactionPosting)
currentTransactionPosting = nil
}
data.Transactions = append(data.Transactions, currentTransactionEntry)
currentTransactionEntry = nil
currentTransactionPosting = nil
}
return currentTransactionEntry, currentTransactionPosting
}
func (r *beancountDataReader) readAndSetOption(ctx core.Context, lineIndex int, items []string) {
if r.getNotEmptyItemsCount(items) != 3 {
log.Warnf(ctx, "[beancount_data_reader.readAndSetOption] cannot parse account type name option line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
return
}
optionName := r.getNotEmptyItemByIndex(items, 1)
optionValue := r.getNotEmptyItemByIndex(items, 2)
switch optionName {
case beancountOptionAssetsAccountTypeName:
r.setAccountTypeNameMap(beancountAssetsAccountType, optionValue)
break
case beancountOptionLiabilitiesAccountTypeName:
r.setAccountTypeNameMap(beancountLiabilitiesAccountType, optionValue)
break
case beancountOptionEquityAccountTypeName:
r.setAccountTypeNameMap(beancountEquityAccountType, optionValue)
break
case beancountOptionIncomeAccountTypeName:
r.setAccountTypeNameMap(beancountIncomeAccountType, optionValue)
break
case beancountOptionExpenseAccountTypeName:
r.setAccountTypeNameMap(beancountExpensesAccountType, optionValue)
break
default:
log.Warnf(ctx, "[beancount_data_reader.readAndSetOption] skip option line#%d \"%s\"", lineIndex, strings.Join(items, " "))
break
}
}
func (r *beancountDataReader) readAndSetTags(ctx core.Context, lineIndex int, items []string, currentTags []string, pushTag bool) []string {
if r.getNotEmptyItemsCount(items) != 2 {
log.Warnf(ctx, "[beancount_data_reader.readAndSetTags] cannot parse push/pop tag line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
return currentTags
}
tag := r.getNotEmptyItemByIndex(items, 1)
if len(tag) < 2 || tag[0] != beancountTagPrefix {
log.Warnf(ctx, "[beancount_data_reader.readAndSetTags] cannot parse push/pop tag line#%d \"%s\", because tag is invalid", lineIndex, strings.Join(items, " "))
return currentTags
}
tag = tag[1:]
if pushTag {
for i := 0; i < len(currentTags); i++ {
if currentTags[i] == tag {
return currentTags
}
}
return append(currentTags, tag)
} else { // pop tag
for i := 0; i < len(currentTags); i++ {
if currentTags[i] == tag {
return append(currentTags[:i], currentTags[i+1:]...)
}
}
return currentTags
}
}
func (r *beancountDataReader) setAccountTypeNameMap(accountType beancountAccountType, accountTypeName string) {
delete(r.accountTypeNameMap, r.accountTypeNameReversedMap[accountType])
r.accountTypeNameMap[accountTypeName] = accountType
r.accountTypeNameReversedMap[accountType] = accountTypeName
}
func (r *beancountDataReader) readAccountLine(ctx core.Context, lineIndex int, items []string, date string, directive beancountDirective, data *beancountData) (*beancountAccount, error) {
if r.getNotEmptyItemsCount(items) < 3 {
log.Warnf(ctx, "[beancount_data_reader.parseAccount] cannot parse account line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
return nil, nil
}
var err error
accountName := r.getNotEmptyItemByIndex(items, 2)
account, exists := data.Accounts[accountName]
if !exists {
account, err = r.createAccount(ctx, data, accountName)
if err != nil {
return nil, err
}
}
if directive == beancountDirectiveOpen {
account.OpenDate = date
return account, nil
} else if directive == beancountDirectiveClose {
account.CloseDate = date
return account, nil
} else {
log.Warnf(ctx, "[beancount_data_reader.parseAccount] cannot parse account line#%d \"%s\", because directive is invalid", lineIndex, strings.Join(items, " "))
return nil, nil
}
}
func (r *beancountDataReader) createAccount(ctx core.Context, data *beancountData, accountName string) (*beancountAccount, error) {
account := &beancountAccount{
Name: accountName,
AccountType: beancountUnknownAccountType,
}
accountNameItems := strings.Split(accountName, beancountAccountNameItemsSeparator)
if len(accountNameItems) > 1 {
accountType, exists := r.accountTypeNameMap[accountNameItems[0]]
if exists {
account.AccountType = accountType
} else {
log.Warnf(ctx, "[beancount_data_reader.createAccount] cannot parse account \"%s\", because account type \"%s\" is invalid", accountName, accountNameItems[0])
return nil, errs.ErrInvalidBeancountFile
}
}
data.Accounts[accountName] = account
return account, nil
}
func (r *beancountDataReader) readTransactionLine(ctx core.Context, lineIndex int, items []string, date string, directive beancountDirective, tags []string) *beancountTransactionEntry {
transactionEntry := &beancountTransactionEntry{
Date: date,
Directive: directive,
Tags: make([]string, 0),
Links: make([]string, 0),
Metadata: make(map[string]string),
}
transactionEntry.Tags = append(transactionEntry.Tags, tags...)
allTags := make(map[string]bool, len(transactionEntry.Tags))
for _, tag := range transactionEntry.Tags {
allTags[tag] = true
}
// YYYY-MM-DD [txn|Flag] [[Payee] Narration] [#tag] [ˆlink]
payeeNarrationFirstIndex := 2
payeeNarrationLastIndex := len(items) - 1
// parse remain items
for i := payeeNarrationFirstIndex; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
if item[0] == beancountCommentPrefix { // ; comment
if i-1 < payeeNarrationLastIndex {
payeeNarrationLastIndex = i - 1
}
break
}
if item[0] == beancountTagPrefix { // [#tag]
tagName := item[1:]
if _, exists := allTags[tagName]; !exists {
transactionEntry.Tags = append(transactionEntry.Tags, tagName)
allTags[tagName] = true
}
if i-1 < payeeNarrationLastIndex {
payeeNarrationLastIndex = i - 1
}
} else if item[0] == beancountLinkPrefix { // [ˆlink]
transactionEntry.Links = append(transactionEntry.Links, item[1:])
if i-1 < payeeNarrationLastIndex {
payeeNarrationLastIndex = i - 1
}
}
}
if payeeNarrationLastIndex-payeeNarrationFirstIndex >= 1 {
transactionEntry.Payee = items[payeeNarrationFirstIndex]
transactionEntry.Narration = items[payeeNarrationFirstIndex+1]
} else if payeeNarrationLastIndex-payeeNarrationFirstIndex >= 0 {
transactionEntry.Narration = items[payeeNarrationFirstIndex]
}
return transactionEntry
}
func (r *beancountDataReader) readTransactionPostingLine(ctx core.Context, lineIndex int, items []string, data *beancountData, hasFlag bool) (*beancountPosting, error) {
// [Flag] Account Amount [{Cost}] [@ Price]
accountNameExpectedIndex := 0
if hasFlag {
accountNameExpectedIndex = 1
}
if r.getNotEmptyItemsCount(items) <= accountNameExpectedIndex {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
return nil, nil
}
accountName, accountNameActualIndex := r.getNotEmptyItemAndIndexByIndex(items, accountNameExpectedIndex)
if accountName == "" || accountNameActualIndex < 0 {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing account name", lineIndex, strings.Join(items, " "))
return nil, errs.ErrMissingAccountData
}
transactionPositing := &beancountPosting{
Account: accountName,
Metadata: make(map[string]string),
}
amountActualLastIndex := -1
transactionPositing.OriginalAmount, amountActualLastIndex = r.getOriginalAmountAndLastIndexFromIndex(items, accountNameActualIndex+1)
if transactionPositing.OriginalAmount == "" || amountActualLastIndex < 0 {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing amount", lineIndex, strings.Join(items, " "))
return nil, errs.ErrAmountInvalid
}
finalAmount, err := evaluateBeancountAmountExpression(ctx, transactionPositing.OriginalAmount)
if err != nil {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot evaluate amount expression in line#%d \"%s\", because %s", lineIndex, strings.Join(items, " "), err.Error())
return nil, errs.ErrAmountInvalid
} else {
transactionPositing.Amount = finalAmount
}
commodityActualIndex := -1
transactionPositing.Commodity, commodityActualIndex = r.getNotEmptyItemAndIndexFromIndex(items, amountActualLastIndex+1)
if transactionPositing.Commodity == "" || commodityActualIndex < 0 {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing commodity", lineIndex, strings.Join(items, " "))
return nil, errs.ErrInvalidBeancountFile
}
if strings.ToUpper(transactionPositing.Commodity) != transactionPositing.Commodity { // The syntax for a currency is a word all in capital letters
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because commodity name is not capital letters", lineIndex, strings.Join(items, " "))
return nil, errs.ErrInvalidBeancountFile
}
// parse remain items
if commodityActualIndex > 0 {
for i := commodityActualIndex + 1; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
if item[0] == beancountCommentPrefix { // ; comment
break
}
if len(item) == 2 && item[0] == beancountPricePrefix && item[1] == beancountPricePrefix { // [@@ TotalCost]
totalCost, totalCostActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, i+1)
if totalCostActualIndex > 0 {
transactionPositing.TotalCost = totalCost
i = totalCostActualIndex
totalCostCommodity, totalCostCommodityActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, totalCostActualIndex+1)
if totalCostCommodityActualIndex > 0 {
transactionPositing.TotalCostCommodity = totalCostCommodity
i = totalCostCommodityActualIndex
}
}
} else if len(item) == 1 && item[0] == beancountPricePrefix { // [@ Price]
price, priceActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, i+1)
if priceActualIndex > 0 {
transactionPositing.Price = price
i = priceActualIndex
priceCommodity, priceCommodityActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, priceActualIndex+1)
if priceCommodityActualIndex > 0 {
transactionPositing.PriceCommodity = priceCommodity
i = priceCommodityActualIndex
}
}
}
}
}
if transactionPositing.Account != "" {
_, exists := data.Accounts[transactionPositing.Account]
if !exists {
_, err := r.createAccount(ctx, data, transactionPositing.Account)
if err != nil {
return nil, err
}
}
}
return transactionPositing, nil
}
func (r *beancountDataReader) readTransactionMetadataLine(ctx core.Context, lineIndex int, items []string) []string {
key := r.getNotEmptyItemByIndex(items, 0)
value := r.getNotEmptyItemByIndex(items, 1)
if key == "" || value == "" {
log.Warnf(ctx, "[beancount_data_reader.readTransactionMetadataLine] cannot parse metadata line#%d \"%s\", because key or value is empty", lineIndex, strings.Join(items, " "))
return nil
}
if len(key) == 0 || key[len(key)-1] != beancountMetadataKeySuffix {
log.Warnf(ctx, "[beancount_data_reader.readTransactionMetadataLine] cannot parse metadata line#%d \"%s\", because key is invalid correct", lineIndex, strings.Join(items, " "))
return nil
}
key = key[:len(key)-1]
return []string{key, value}
}
func (r *beancountDataReader) getNotEmptyItemByIndex(items []string, index int) string {
item, _ := r.getNotEmptyItemAndIndexByIndex(items, index)
return item
}
func (r *beancountDataReader) getNotEmptyItemAndIndexByIndex(items []string, index int) (string, int) {
count := -1
for i := 0; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
count++
if count == index {
return items[i], i
}
}
return "", -1
}
func (r *beancountDataReader) getNotEmptyItemAndIndexFromIndex(items []string, startIndex int) (string, int) {
for i := startIndex; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
return item, i
}
return "", -1
}
func (r *beancountDataReader) getNotEmptyItemsCount(items []string) int {
count := 0
for i := 0; i < len(items); i++ {
if len(items[i]) > 0 {
count++
}
}
return count
}
func (r *beancountDataReader) getOriginalAmountAndLastIndexFromIndex(items []string, startIndex int) (string, int) {
amountBuilder := strings.Builder{}
lastIndex := -1
for i := startIndex; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
valid := true
// The Amount in “Postings” can also be an arithmetic expression using ( ) * / - +
for j := 0; j < len(item); j++ {
if !(item[j] >= '0' && item[j] <= '9') && item[j] != '.' && item[j] != '(' && item[j] != ')' &&
item[j] != '*' && item[j] != '/' && item[j] != '-' && item[j] != '+' {
valid = false
break
}
}
if !valid {
break
}
if amountBuilder.Len() > 0 {
amountBuilder.WriteRune(' ')
}
amountBuilder.WriteString(item)
lastIndex = i
}
return amountBuilder.String(), lastIndex
}
func createNewBeancountDataReader(ctx core.Context, data []byte) (*beancountDataReader, error) {
fallback := unicode.UTF8.NewDecoder()
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
csvReader := csv.NewReader(reader)
csvReader.Comma = ' '
csvReader.FieldsPerRecord = -1
allData := make([][]string, 0)
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[beancount_data_reader.createNewBeancountDataReader] cannot parse data, because %s", err.Error())
return nil, errs.ErrInvalidBeancountFile
}
allData = append(allData, items)
}
return &beancountDataReader{
accountTypeNameMap: map[string]beancountAccountType{
beancountDefaultAssetsAccountTypeName: beancountAssetsAccountType,
beancountDefaultLiabilitiesAccountTypeName: beancountLiabilitiesAccountType,
beancountDefaultEquityAccountTypeName: beancountEquityAccountType,
beancountDefaultIncomeAccountTypeName: beancountIncomeAccountType,
beancountDefaultExpenseAccountTypeName: beancountExpensesAccountType,
},
accountTypeNameReversedMap: map[beancountAccountType]string{
beancountAssetsAccountType: beancountDefaultAssetsAccountTypeName,
beancountLiabilitiesAccountType: beancountDefaultLiabilitiesAccountTypeName,
beancountEquityAccountType: beancountDefaultEquityAccountTypeName,
beancountIncomeAccountType: beancountDefaultIncomeAccountTypeName,
beancountExpensesAccountType: beancountDefaultExpenseAccountTypeName,
},
allData: allData,
}, nil
}
@@ -0,0 +1,520 @@
package beancount
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestBeancountDataReaderRead(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"; Test Beancount Data\n"+
"option \"name_assets\" \"AssetsAccount\"\n"+
"option \"name_liabilities\" \"LiabilitiesAccount\"\n"+
"option \"name_equity\" \"EquityAccount\"\n"+
"option \"name_income\" \"IncomeAccount\"\n"+
"option \"name_expenses\" \"ExpensesAccount\"\n"+
"\n"+
"2024-01-01 open AssetsAccount:TestAccount\n"+
"2024-01-02 open LiabilitiesAccount:TestAccount2\n"+
"2024-01-03 open EquityAccount:Opening-Balances\n"+
"\n"+
"; The following transactions with tag1 and tag2\n"+
"pushtag #tag1\n"+
"pushtag #tag2\n"+
"\n"+
"2024-01-05 * \"Payee Name\" \"Foo Bar\" #tag3 #tag4 ^test-link\n"+
" IncomeAccount:TestCategory -123.45 CNY\n"+
" AssetsAccount:TestAccount 123.45 CNY\n"+
"; The following transactions with tag2\n"+
"poptag #tag1\n"+
"2024-01-06 * \"test\n#test2\" #tag5 #tag6 ^test-link2\n"+
" LiabilitiesAccount:TestAccount2 -0.12 USD\n"+
" ExpensesAccount:TestCategory2 0.12 USD\n"+
"2024-01-07 close AssetsAccount:TestAccount\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 5, len(actualData.Accounts))
assert.Equal(t, "AssetsAccount:TestAccount", actualData.Accounts["AssetsAccount:TestAccount"].Name)
assert.Equal(t, beancountAssetsAccountType, actualData.Accounts["AssetsAccount:TestAccount"].AccountType)
assert.Equal(t, "2024-01-01", actualData.Accounts["AssetsAccount:TestAccount"].OpenDate)
assert.Equal(t, "2024-01-07", actualData.Accounts["AssetsAccount:TestAccount"].CloseDate)
assert.Equal(t, "LiabilitiesAccount:TestAccount2", actualData.Accounts["LiabilitiesAccount:TestAccount2"].Name)
assert.Equal(t, beancountLiabilitiesAccountType, actualData.Accounts["LiabilitiesAccount:TestAccount2"].AccountType)
assert.Equal(t, "2024-01-02", actualData.Accounts["LiabilitiesAccount:TestAccount2"].OpenDate)
assert.Equal(t, 2, len(actualData.Transactions))
assert.Equal(t, "2024-01-05", actualData.Transactions[0].Date)
assert.Equal(t, "Payee Name", actualData.Transactions[0].Payee)
assert.Equal(t, "Foo Bar", actualData.Transactions[0].Narration)
assert.Equal(t, 2, len(actualData.Transactions[0].Postings))
assert.Equal(t, "IncomeAccount:TestCategory", actualData.Transactions[0].Postings[0].Account)
assert.Equal(t, "-123.45", actualData.Transactions[0].Postings[0].Amount)
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[0].Commodity)
assert.Equal(t, "AssetsAccount:TestAccount", actualData.Transactions[0].Postings[1].Account)
assert.Equal(t, "123.45", actualData.Transactions[0].Postings[1].Amount)
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[1].Commodity)
assert.Equal(t, 4, len(actualData.Transactions[0].Tags))
assert.Equal(t, actualData.Transactions[0].Tags[0], "tag1")
assert.Equal(t, actualData.Transactions[0].Tags[1], "tag2")
assert.Equal(t, actualData.Transactions[0].Tags[2], "tag3")
assert.Equal(t, actualData.Transactions[0].Tags[3], "tag4")
assert.Equal(t, 1, len(actualData.Transactions[0].Links))
assert.Equal(t, actualData.Transactions[0].Links[0], "test-link")
assert.Equal(t, "2024-01-06", actualData.Transactions[1].Date)
assert.Equal(t, "", actualData.Transactions[1].Payee)
assert.Equal(t, "test\n#test2", actualData.Transactions[1].Narration)
assert.Equal(t, 2, len(actualData.Transactions[1].Postings))
assert.Equal(t, "LiabilitiesAccount:TestAccount2", actualData.Transactions[1].Postings[0].Account)
assert.Equal(t, "-0.12", actualData.Transactions[1].Postings[0].Amount)
assert.Equal(t, "USD", actualData.Transactions[1].Postings[0].Commodity)
assert.Equal(t, "ExpensesAccount:TestCategory2", actualData.Transactions[1].Postings[1].Account)
assert.Equal(t, "0.12", actualData.Transactions[1].Postings[1].Amount)
assert.Equal(t, "USD", actualData.Transactions[1].Postings[1].Commodity)
assert.Equal(t, 3, len(actualData.Transactions[1].Tags))
assert.Equal(t, actualData.Transactions[1].Tags[0], "tag2")
assert.Equal(t, actualData.Transactions[1].Tags[1], "tag5")
assert.Equal(t, actualData.Transactions[1].Tags[2], "tag6")
assert.Equal(t, 1, len(actualData.Transactions[1].Links))
assert.Equal(t, actualData.Transactions[1].Links[0], "test-link2")
}
func TestBeancountDataReaderRead_EmptyContent(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestBeancountDataReaderRead_UnsupportedInclude(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte("include \"other.beancount\""))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrBeancountFileNotSupportInclude.Message)
}
func TestBeancountDataReaderRead_SkipUnsupportedDirective(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"plugin \"beancount.plugins.plugin_name\"\n"+
"unknown directive\n"+
"2024-01-01 commodity USD\n"+
"2024-01-01 price USD 1.08 CAD\n"+
"2024-01-01 note Assets:Test \"some text\"\n"+
"2024-01-01 document Assets:Test \"scheme://path\"\n"+
"2024-01-01 event \"location\" \"address\"\n"+
"2024-01-01 balance Assets:Test 100.00 USD\n"+
"2024-01-01 pad Assets:Test Equity:Opening-Balances\n"+
"2024-01-01 query \"Name\" \"\nSELECT FIELDS FROM TABLE\"\n"+
"2024-01-01 custom \"Type\" \"Value\"\n"+
"2024-01-01 unknown directive\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.Nil(t, err)
}
func TestBeancountDataReaderReadAndSetOption_AccountTypeName(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"option \"name_assets\" \"A\"\n"+
"option \"name_liabilities\" \"L\"\n"+
"option \"name_equity\" \"E\"\n"+
"\n"+
"2024-01-01 open A:TestAccount\n"+
"2024-01-02 open L:TestAccount2\n"+
"2024-01-03 open E:Opening-Balances\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 3, len(actualData.Accounts))
assert.Equal(t, "A:TestAccount", actualData.Accounts["A:TestAccount"].Name)
assert.Equal(t, beancountAssetsAccountType, actualData.Accounts["A:TestAccount"].AccountType)
assert.Equal(t, "L:TestAccount2", actualData.Accounts["L:TestAccount2"].Name)
assert.Equal(t, beancountLiabilitiesAccountType, actualData.Accounts["L:TestAccount2"].AccountType)
assert.Equal(t, "E:Opening-Balances", actualData.Accounts["E:Opening-Balances"].Name)
assert.Equal(t, beancountEquityAccountType, actualData.Accounts["E:Opening-Balances"].AccountType)
assert.True(t, actualData.Accounts["E:Opening-Balances"].isOpeningBalanceEquityAccount())
}
func TestBeancountDataReaderReadAndSetOption_InvalidLineOrUnsupportedOption(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"option \"test\" \"Test\" \"Test2\"\n"+
"option \"test\" \"Test\"\n"+
"option \"test\"\n"+
"option \n"+
"option\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.Nil(t, err)
}
func TestBeancountDataReaderReadAndSetTags(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"pushtag #tag1\n"+
"pushtag #tag2\n"+
"pushtag #tag2\n"+
"pushtag #tag1\n"+
"\n"+
"2024-01-01 * #tag3 #tag4\n"+
"poptag #tag1\n"+
"poptag #tag2\n"+
"pushtag\n"+
"pushtag \n"+
"pushtag tag\n"+
"2024-01-02 * #tag5 #tag6\n"+
"poptag #tag1\n"+
"poptag #tag2\n"+
"poptag\n"+
"poptag \n"+
"2024-01-03 * #tag5 #tag6\n"+
"pushtag #tag3\n"+
"pushtag #tag6\n"+
"2024-01-04 * #tag5 #tag6\n"+
"2024-01-05 * #tag5 #tag6 #tag6 #tag5\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 5, len(actualData.Transactions))
assert.Equal(t, 4, len(actualData.Transactions[0].Tags))
assert.Equal(t, actualData.Transactions[0].Tags[0], "tag1")
assert.Equal(t, actualData.Transactions[0].Tags[1], "tag2")
assert.Equal(t, actualData.Transactions[0].Tags[2], "tag3")
assert.Equal(t, actualData.Transactions[0].Tags[3], "tag4")
assert.Equal(t, 2, len(actualData.Transactions[1].Tags))
assert.Equal(t, actualData.Transactions[1].Tags[0], "tag5")
assert.Equal(t, actualData.Transactions[1].Tags[1], "tag6")
assert.Equal(t, 2, len(actualData.Transactions[2].Tags))
assert.Equal(t, actualData.Transactions[2].Tags[0], "tag5")
assert.Equal(t, actualData.Transactions[2].Tags[1], "tag6")
assert.Equal(t, 3, len(actualData.Transactions[3].Tags))
assert.Equal(t, actualData.Transactions[3].Tags[0], "tag3")
assert.Equal(t, actualData.Transactions[3].Tags[1], "tag6")
assert.Equal(t, actualData.Transactions[3].Tags[2], "tag5")
assert.Equal(t, 3, len(actualData.Transactions[4].Tags))
assert.Equal(t, actualData.Transactions[4].Tags[0], "tag3")
assert.Equal(t, actualData.Transactions[4].Tags[1], "tag6")
assert.Equal(t, actualData.Transactions[4].Tags[2], "tag5")
}
func TestBeancountDataReaderReadAccountLine_InvalidLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 open\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 0, len(actualData.Accounts))
}
func TestBeancountDataReaderReadAccountLine_InvalidAccountType(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 open Test:TestAccount\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
reader, err = createNewBeancountDataReader(context, []byte(""+
"option \"name_assets\" \"A\"\n"+
"\n"+
"2024-01-01 open Assets:TestAccount\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
func TestBeancountDataReaderReadTransactionLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
"2024-01-02 * \"test\ttest2\ntest3\" ; some comment\n"+
"2024-01-03 ! \"test\" \"test2\"\n"+
"2024-01-04 P \"test\" #tag #tag2 ; some comment\n"+
"2024-01-05 txn \"test\" ^scheme://path/to/test/link ; some comment\n"+
"2024-01-06 txn ; \"test\" \"test2\" #tag ^link\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 6, len(actualData.Transactions))
assert.Equal(t, "2024-01-01", actualData.Transactions[0].Date)
assert.Equal(t, beancountDirectiveCompletedTransaction, actualData.Transactions[0].Directive)
assert.Equal(t, "", actualData.Transactions[0].Payee)
assert.Equal(t, "", actualData.Transactions[0].Narration)
assert.Equal(t, "2024-01-02", actualData.Transactions[1].Date)
assert.Equal(t, beancountDirectiveCompletedTransaction, actualData.Transactions[1].Directive)
assert.Equal(t, "", actualData.Transactions[1].Payee)
assert.Equal(t, "test\ttest2\ntest3", actualData.Transactions[1].Narration)
assert.Equal(t, "2024-01-03", actualData.Transactions[2].Date)
assert.Equal(t, beancountDirectiveInCompleteTransaction, actualData.Transactions[2].Directive)
assert.Equal(t, "test", actualData.Transactions[2].Payee)
assert.Equal(t, "test2", actualData.Transactions[2].Narration)
assert.Equal(t, "2024-01-04", actualData.Transactions[3].Date)
assert.Equal(t, beancountDirectivePaddingTransaction, actualData.Transactions[3].Directive)
assert.Equal(t, "", actualData.Transactions[3].Payee)
assert.Equal(t, "test", actualData.Transactions[3].Narration)
assert.Equal(t, 2, len(actualData.Transactions[3].Tags))
assert.Equal(t, actualData.Transactions[3].Tags[0], "tag")
assert.Equal(t, actualData.Transactions[3].Tags[1], "tag2")
assert.Equal(t, "2024-01-05", actualData.Transactions[4].Date)
assert.Equal(t, beancountDirectiveTransaction, actualData.Transactions[4].Directive)
assert.Equal(t, "", actualData.Transactions[4].Payee)
assert.Equal(t, "test", actualData.Transactions[4].Narration)
assert.Equal(t, 1, len(actualData.Transactions[4].Links))
assert.Equal(t, actualData.Transactions[4].Links[0], "scheme://path/to/test/link")
assert.Equal(t, "2024-01-06", actualData.Transactions[5].Date)
assert.Equal(t, beancountDirectiveTransaction, actualData.Transactions[5].Directive)
assert.Equal(t, "", actualData.Transactions[5].Payee)
assert.Equal(t, "", actualData.Transactions[5].Narration)
}
func TestBeancountDataReaderReadTransactionPostingLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory -123.45 CNY ; some comment\n"+
" Assets:TestAccount 123.45 CNY\n"+
"2024-01-02 *\n"+
" Liabilities:TestAccount2 -0.23 USD ; some comment\n"+
" Expenses:TestCategory2 0.12 USD @@ 0.84 CNY\n"+
" Expenses:TestCategory3 0.11 USD @ 7.12 CNY\n"+
" ! Expenses:TestCategory4 0.00 USD {0.00 CNY}\n"+
" Expenses:TestCategory5 \n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 2, len(actualData.Transactions))
assert.Equal(t, "2024-01-01", actualData.Transactions[0].Date)
assert.Equal(t, 2, len(actualData.Transactions[0].Postings))
assert.Equal(t, "Income:TestCategory", actualData.Transactions[0].Postings[0].Account)
assert.Equal(t, "-123.45", actualData.Transactions[0].Postings[0].Amount)
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[0].Commodity)
assert.Equal(t, "Assets:TestAccount", actualData.Transactions[0].Postings[1].Account)
assert.Equal(t, "123.45", actualData.Transactions[0].Postings[1].Amount)
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[1].Commodity)
assert.Equal(t, "2024-01-02", actualData.Transactions[1].Date)
assert.Equal(t, 4, len(actualData.Transactions[1].Postings))
assert.Equal(t, "Liabilities:TestAccount2", actualData.Transactions[1].Postings[0].Account)
assert.Equal(t, "-0.23", actualData.Transactions[1].Postings[0].Amount)
assert.Equal(t, "USD", actualData.Transactions[1].Postings[0].Commodity)
assert.Equal(t, "Expenses:TestCategory2", actualData.Transactions[1].Postings[1].Account)
assert.Equal(t, "0.12", actualData.Transactions[1].Postings[1].Amount)
assert.Equal(t, "USD", actualData.Transactions[1].Postings[1].Commodity)
assert.Equal(t, "0.84", actualData.Transactions[1].Postings[1].TotalCost)
assert.Equal(t, "CNY", actualData.Transactions[1].Postings[1].TotalCostCommodity)
assert.Equal(t, "Expenses:TestCategory3", actualData.Transactions[1].Postings[2].Account)
assert.Equal(t, "0.11", actualData.Transactions[1].Postings[2].Amount)
assert.Equal(t, "USD", actualData.Transactions[1].Postings[2].Commodity)
assert.Equal(t, "7.12", actualData.Transactions[1].Postings[2].Price)
assert.Equal(t, "CNY", actualData.Transactions[1].Postings[2].PriceCommodity)
assert.Equal(t, "0.00", actualData.Transactions[1].Postings[3].Amount)
assert.Equal(t, "USD", actualData.Transactions[1].Postings[3].Commodity)
}
func TestBeancountDataReaderReadTransactionPostingLine_AmountExpression(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory (1.2-3.4) * 5.6 / 7.8 CNY\n"+
" Assets:TestAccount 1.2 * 3.4/-5.6 - 7.8 CNY\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 1, len(actualData.Transactions))
assert.Equal(t, "2024-01-01", actualData.Transactions[0].Date)
assert.Equal(t, 2, len(actualData.Transactions[0].Postings))
assert.Equal(t, "Income:TestCategory", actualData.Transactions[0].Postings[0].Account)
assert.Equal(t, "(1.2-3.4) * 5.6 / 7.8", actualData.Transactions[0].Postings[0].OriginalAmount)
assert.Equal(t, "-1.58", actualData.Transactions[0].Postings[0].Amount)
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[0].Commodity)
assert.Equal(t, "Assets:TestAccount", actualData.Transactions[0].Postings[1].Account)
assert.Equal(t, "1.2 * 3.4/-5.6 - 7.8", actualData.Transactions[0].Postings[1].OriginalAmount)
assert.Equal(t, "-8.53", actualData.Transactions[0].Postings[1].Amount)
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[1].Commodity)
}
func TestBeancountDataReaderReadTransactionPostingLine_InvalidAmountExpression(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory (1.2-3.4)*5.6/0 CNY\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
reader, err = createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount abc CNY\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestBeancountDataReaderReadTransactionPostingLine_InvalidAccountType(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory -123.45 CNY\n"+
" Test:TestAccount 123.45 CNY\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
func TestBeancountDataReaderReadTransactionPostingLine_InvalidCommodity(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory -123.45 cny\n"+
" Assets:TestAccount 123.45 cny\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
func TestBeancountDataReaderReadTransactionPostingLine_MissingAmount(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 1, len(actualData.Transactions))
assert.Equal(t, 0, len(actualData.Transactions[0].Postings))
reader, err = createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount \n"))
assert.Nil(t, err)
actualData, err = reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 1, len(actualData.Transactions))
assert.Equal(t, 0, len(actualData.Transactions[0].Postings))
}
func TestBeancountDataReaderReadTransactionPostingLine_MissingCommodity(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount 123.45\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
reader, err = createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount 123.45 \n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
func TestBeancountDataReaderReadTransactionMetadataLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" key: value\n"+
" key2: \"value 2\"\n"+
" key3: \n"+
" key4: \"\"\n"+
" key5 : \"\"\n"+
" key2: \"new value\"\n"+
" Income:TestCategory -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"+
"2024-01-02 *\n"+
" Liabilities:TestAccount2 -0.23 USD\n"+
" key6: value6\n"+
" key7: \"value 7\"\n"+
" key8: \n"+
" key9: \"\"\n"+
" key0 : \"\"\n"+
" key6: \"new value\"\n"+
" Expenses:TestCategory2 0.12 USD\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 2, len(actualData.Transactions))
assert.Equal(t, "2024-01-01", actualData.Transactions[0].Date)
assert.Equal(t, 2, len(actualData.Transactions[0].Postings))
assert.Equal(t, 2, len(actualData.Transactions[0].Metadata))
assert.Equal(t, "value", actualData.Transactions[0].Metadata["key"])
assert.Equal(t, "value 2", actualData.Transactions[0].Metadata["key2"])
assert.Equal(t, "2024-01-02", actualData.Transactions[1].Date)
assert.Equal(t, 2, len(actualData.Transactions[1].Postings))
assert.Equal(t, 2, len(actualData.Transactions[1].Postings[0].Metadata))
assert.Equal(t, "value6", actualData.Transactions[1].Postings[0].Metadata["key6"])
assert.Equal(t, "value 7", actualData.Transactions[1].Postings[0].Metadata["key7"])
assert.Equal(t, 0, len(actualData.Transactions[1].Postings[1].Metadata))
}
@@ -0,0 +1,41 @@
package beancount
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBeancountAccount_IsOpeningBalanceEquityAccount_True(t *testing.T) {
account := beancountAccount{
AccountType: beancountEquityAccountType,
Name: "Equity:Opening-Balances",
}
assert.True(t, account.isOpeningBalanceEquityAccount())
account = beancountAccount{
AccountType: beancountEquityAccountType,
Name: "E:Opening-Balances",
}
assert.True(t, account.isOpeningBalanceEquityAccount())
}
func TestBeancountAccount_IsOpeningBalanceEquityAccount_False(t *testing.T) {
account := beancountAccount{
AccountType: beancountAssetsAccountType,
Name: "Equity:Opening-Balances",
}
assert.False(t, account.isOpeningBalanceEquityAccount())
account = beancountAccount{
AccountType: beancountEquityAccountType,
Name: "Opening-Balances",
}
assert.False(t, account.isOpeningBalanceEquityAccount())
account = beancountAccount{
AccountType: beancountEquityAccountType,
Name: "Equity:Other",
}
assert.False(t, account.isOpeningBalanceEquityAccount())
}
@@ -0,0 +1,49 @@
package beancount
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var beancountTransactionTypeNameMapping = 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)),
}
// beancountTransactionDataImporter defines the structure of Beancount importer for transaction data
type beancountTransactionDataImporter struct {
}
// Initialize a beancount transaction data importer singleton instance
var (
BeancountTransactionDataImporter = &beancountTransactionDataImporter{}
)
// ParseImportedData returns the imported data by parsing the Beancount transaction data
func (c *beancountTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
beancountDataReader, err := createNewBeancountDataReader(ctx, data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
beancountData, err := beancountDataReader.read(ctx)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable, err := createNewBeancountTransactionDataTable(beancountData)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(beancountTransactionTypeNameMapping, "", "", BEANCOUNT_TRANSACTION_TAG_SEPARATOR)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,358 @@
package beancount
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 TestBeancountTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"+
"2024-09-02 *\n"+
" Income:TestCategory -0.12 CNY\n"+
" Assets:TestAccount 0.12 CNY\n"+
"2024-09-03 *\n"+
" Assets:TestAccount -1.00 CNY\n"+
" Expenses:TestCategory2 1.00 CNY\n"+
"2024-09-04 *\n"+
" Assets:TestAccount -0.05 CNY\n"+
" Assets:TestAccount2 0.05 CNY\n"), 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, "Assets:TestAccount", 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, "Assets:TestAccount", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Income:TestCategory", 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, "Assets:TestAccount", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Expenses:TestCategory2", 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, "Assets:TestAccount", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Expenses:TestCategory2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Income:TestCategory", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
}
func TestBeancountTransactionDataFileParseImportedData_MinimumValidData2(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Assets:TestAccount 123.45 CNY\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
"2024-09-02 *\n"+
" Assets:TestAccount 0.12 CNY\n"+
" Income:TestCategory -0.12 CNY\n"+
"2024-09-03 *\n"+
" Expenses:TestCategory2 1.00 CNY\n"+
" Assets:TestAccount -1.00 CNY\n"+
"2024-09-04 *\n"+
" Assets:TestAccount2 0.05 CNY\n"+
" Assets:TestAccount -0.05 CNY\n"), 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, "Assets:TestAccount", 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, "Assets:TestAccount", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Income:TestCategory", 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, "Assets:TestAccount", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Expenses:TestCategory2", 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, "Assets:TestAccount", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Expenses:TestCategory2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Income:TestCategory", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
}
func TestBeancountTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024/09/01 *\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestBeancountTransactionDataFileParseImportedData_ParseValidCurrency(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Assets:TestAccount -0.12 USD\n"+
" Assets:TestAccount2 0.84 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, int64(12), allNewTransactions[0].Amount)
assert.Equal(t, int64(84), allNewTransactions[0].RelatedAccountAmount)
assert.Equal(t, "Assets:TestAccount", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, "CNY", allNewTransactions[0].OriginalDestinationAccountCurrency)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
assert.Equal(t, "USD", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
}
func TestBeancountTransactionDataFileParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Equity:Opening-Balances -abc CNY\n"+
" Assets:TestAccount abc CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Equity:Opening-Balances -1/0 CNY\n"+
" Assets:TestAccount 1/0 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestBeancountTransactionDataFileParseImportedData_ParseDescription(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"foo bar\t#test\n\"\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"+
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Income:TestCategory -0.12 CNY\n"+
" Assets:TestAccount 0.12 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, "foo bar\t#test\n", allNewTransactions[0].Comment)
assert.Equal(t, "Hello\nWorld", allNewTransactions[1].Comment)
}
func TestBeancountTransactionDataFileParseImportedData_InvalidTransaction(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Assets:TestAccount 0.11 CNY\n"+
" Assets:TestAccount2 0.11 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Expenses:TestCategory -0.11 CNY\n"+
" Expenses:TestCategory2 0.11 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Income:TestCategory -0.11 CNY\n"+
" Income:TestCategory2 0.11 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Equity:TestCategory -0.11 CNY\n"+
" Equity:TestCategory2 0.11 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
}
func TestBeancountTransactionDataFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Assets:TestAccount -0.23 CNY\n"+
" Assets:TestAccount2 0.11 CNY\n"+
" Assets:TestAccount3 0.12 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotSupportedSplitTransactions.Message)
}
func TestBeancountTransactionDataFileParseImportedData_MissingTransactionRequiredData(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
// Missing Transaction Time
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"* \"narration\"\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
// Missing Account Name
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"narration\"\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" 123.45 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
// Missing Amount
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"narration\"\n"+
" Equity:Opening-Balances\n"+
" Assets:TestAccount\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
// Missing Commodity
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"narration\"\n"+
" Equity:Opening-Balances -123.45\n"+
" Assets:TestAccount 123.45\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
@@ -0,0 +1,248 @@
package beancount
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 beancountTransactionSupportedColumns = 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_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,
}
var BEANCOUNT_TRANSACTION_TAG_SEPARATOR = "#"
// beancountTransactionDataTable defines the structure of Beancount transaction data table
type beancountTransactionDataTable struct {
allData []*beancountTransactionEntry
accountMap map[string]*beancountAccount
}
// beancountTransactionDataRow defines the structure of Beancount transaction data row
type beancountTransactionDataRow struct {
dataTable *beancountTransactionDataTable
data *beancountTransactionEntry
finalItems map[datatable.TransactionDataTableColumn]string
}
// beancountTransactionDataRowIterator defines the structure of Beancount transaction data row iterator
type beancountTransactionDataRowIterator struct {
dataTable *beancountTransactionDataTable
currentIndex int
}
// HasColumn returns whether the transaction data table has specified column
func (t *beancountTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
_, exists := beancountTransactionSupportedColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *beancountTransactionDataTable) TransactionRowCount() int {
return len(t.allData)
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *beancountTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &beancountTransactionDataRowIterator{
dataTable: t,
currentIndex: -1,
}
}
// IsValid returns whether this row is valid data for importing
func (r *beancountTransactionDataRow) IsValid() bool {
return true
}
// GetData returns the data in the specified column type
func (r *beancountTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
_, exists := beancountTransactionSupportedColumns[column]
if exists {
return r.finalItems[column]
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *beancountTransactionDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allData)
}
// Next returns the next transaction data row
func (t *beancountTransactionDataRowIterator) 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 &beancountTransactionDataRow{
dataTable: t.dataTable,
data: data,
finalItems: rowItems,
}, nil
}
func (t *beancountTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, beancountEntry *beancountTransactionEntry) (map[datatable.TransactionDataTableColumn]string, error) {
data := make(map[datatable.TransactionDataTableColumn]string, len(beancountTransactionSupportedColumns))
if beancountEntry.Date == "" {
return nil, errs.ErrMissingTransactionTime
}
// Beancount supports the international ISO 8601 standard format for dates, with dashes or the same ordering with slashes
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = strings.ReplaceAll(beancountEntry.Date, "/", "-") + " 00:00:00"
if len(beancountEntry.Postings) == 2 {
splitData1 := beancountEntry.Postings[0]
splitData2 := beancountEntry.Postings[1]
account1 := t.dataTable.accountMap[splitData1.Account]
account2 := t.dataTable.accountMap[splitData2.Account]
if account1 == nil || account2 == nil {
return nil, errs.ErrMissingAccountData
}
amount1, err := utils.ParseAmount(splitData1.Amount)
if err != nil {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse amount \"%s\", because %s", splitData1.Amount, err.Error())
return nil, errs.ErrAmountInvalid
}
amount2, err := utils.ParseAmount(splitData2.Amount)
if err != nil {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse amount \"%s\", because %s", splitData2.Amount, err.Error())
return nil, errs.ErrAmountInvalid
}
if ((account1.AccountType == beancountEquityAccountType || account1.AccountType == beancountIncomeAccountType) && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType)) ||
((account2.AccountType == beancountEquityAccountType || account2.AccountType == beancountIncomeAccountType) && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType)) { // income
fromAccount := account1
toAccount := account2
toCurrency := splitData2.Commodity
toAmount := amount2
if (account2.AccountType == beancountEquityAccountType || account2.AccountType == beancountIncomeAccountType) && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType) {
fromAccount = account2
toAccount = account1
toCurrency = splitData1.Commodity
toAmount = amount1
}
if fromAccount.isOpeningBalanceEquityAccount() {
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_SUB_CATEGORY] = fromAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = toAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = toCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(toAmount)
} else if account1.AccountType == beancountExpensesAccountType && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) ||
(account2.AccountType == beancountExpensesAccountType && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType)) { // expense
fromAccount := account1
fromCurrency := splitData1.Commodity
fromAmount := amount1
toAccount := account2
if account1.AccountType == beancountExpensesAccountType && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) {
fromAccount = account2
fromCurrency = splitData2.Commodity
fromAmount = amount2
toAccount = account1
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE))
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = toAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-fromAmount)
} else if (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType) &&
(account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) {
var fromAccount, toAccount *beancountAccount
var fromAmount, toAmount int64
var fromCurrency, toCurrency string
if amount1 < 0 {
fromAccount = account1
fromCurrency = splitData1.Commodity
fromAmount = -amount1
toAccount = account2
toCurrency = splitData2.Commodity
toAmount = amount2
} else if amount2 < 0 {
fromAccount = account2
fromCurrency = splitData2.Commodity
fromAmount = -amount2
toAccount = account1
toCurrency = splitData1.Commodity
toAmount = amount1
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transfer transaction, because unexcepted account amounts \"%d\" and \"%d\"", amount1, amount2)
return nil, errs.ErrInvalidBeancountFile
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER))
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(fromAmount)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = toAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = toCurrency
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(toAmount)
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transaction, because unexcepted account types \"%d\" and \"%d\"", account1.AccountType, account2.AccountType)
return nil, errs.ErrThereAreNotSupportedTransactionType
}
} else if len(beancountEntry.Postings) <= 1 {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transaction, because postings count is %d", len(beancountEntry.Postings))
return nil, errs.ErrInvalidBeancountFile
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse split transaction, because postings count is %d", len(beancountEntry.Postings))
return nil, errs.ErrNotSupportedSplitTransactions
}
data[datatable.TRANSACTION_DATA_TABLE_TAGS] = strings.Join(beancountEntry.Tags, BEANCOUNT_TRANSACTION_TAG_SEPARATOR)
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = beancountEntry.Narration
return data, nil
}
func createNewBeancountTransactionDataTable(beancountData *beancountData) (*beancountTransactionDataTable, error) {
if beancountData == nil {
return nil, errs.ErrNotFoundTransactionDataInFile
}
return &beancountTransactionDataTable{
allData: beancountData.Transactions,
accountMap: beancountData.Accounts,
}, nil
}
+67
View File
@@ -0,0 +1,67 @@
package camt
import "encoding/xml"
type camtCreditDebitIndicator string
const (
CAMT_INDICATOR_CREDIT camtCreditDebitIndicator = "CRDT"
CAMT_INDICATOR_DEBIT camtCreditDebitIndicator = "DBIT"
)
type camt053File struct {
XMLName xml.Name `xml:"Document"`
BankToCustomerStatement *camtBankToCustomerStatement `xml:"BkToCstmrStmt"`
}
type camtBankToCustomerStatement struct {
Statements []*camtStatement `xml:"Stmt"`
}
type camtStatement struct {
Account *camtAccount `xml:"Acct"`
Entries []*camtEntry `xml:"Ntry"`
}
type camtAccount struct {
IBAN string `xml:"Id>IBAN"`
OtherIdentification string `xml:"Id>Othr>Id"`
Currency string `xml:"Ccy"`
}
type camtEntry struct {
Amount *camtAmount `xml:"Amt"`
CreditDebitIndicator camtCreditDebitIndicator `xml:"CdtDbtInd"`
BookingDate *camtDate `xml:"BookgDt"`
EntryDetails *camtEntryDetails `xml:"NtryDtls"`
AdditionalEntryInformation string `xml:"AddtlNtryInf"`
}
type camtAmount struct {
Value string `xml:",chardata"`
Currency string `xml:"Ccy,attr"`
}
type camtDate struct {
Date string `xml:"Dt"`
DateTime string `xml:"DtTm"`
}
type camtEntryDetails struct {
TransactionDetails []*camtTransactionDetails `xml:"TxDtls"`
}
type camtTransactionDetails struct {
AmountDetails *camtAmountDetails `xml:"AmtDtls"`
RemittanceInformation *camtRemittanceInformation `xml:"RmtInf"`
AdditionalTransactionInformation string `xml:"AddtlTxInf"`
}
type camtAmountDetails struct {
InstructedAmount *camtAmount `xml:"InstdAmt>Amt"`
TransactionAmount *camtAmount `xml:"TxAmt>Amt"`
}
type camtRemittanceInformation struct {
Unstructured []string `xml:"Ustrd"`
}
+43
View File
@@ -0,0 +1,43 @@
package camt
import (
"bytes"
"encoding/xml"
"golang.org/x/net/html/charset"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
// camt053FileReader defines the structure of camt.053 file reader
type camt053FileReader struct {
xmlDecoder *xml.Decoder
}
// read returns the imported camt.053 data
// Reference: https://www.iso20022.org/message-set/1196/download
func (r *camt053FileReader) read(ctx core.Context) (*camt053File, error) {
file := &camt053File{}
err := r.xmlDecoder.Decode(&file)
if err != nil {
return nil, err
}
return file, nil
}
func createNewCamt053FileReader(data []byte) (*camt053FileReader, error) {
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 &camt053FileReader{
xmlDecoder: xmlDecoder,
}, nil
}
return nil, errs.ErrInvalidXmlFile
}
@@ -0,0 +1,314 @@
package camt
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 camtTransactionSupportedColumns = 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_DESCRIPTION: true,
}
// camtStatementTransactionDataTable defines the structure of camt statement transaction data table
type camtStatementTransactionDataTable struct {
allStatements []*camtStatement
}
// camtStatementTransactionDataRow defines the structure of camt statement transaction data row
type camtStatementTransactionDataRow struct {
dataTable *camtStatementTransactionDataTable
account *camtAccount
entry *camtEntry
transactionDetails *camtTransactionDetails
finalItems map[datatable.TransactionDataTableColumn]string
}
// camtStatementTransactionDataRowIterator defines the structure of camt statement transaction data row iterator
type camtStatementTransactionDataRowIterator struct {
dataTable *camtStatementTransactionDataTable
currentStatementIndex int
currentEntryIndex int
currentTransactionDetailsIndex int
}
// HasColumn returns whether the transaction data table has specified column
func (t *camtStatementTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
_, exists := camtTransactionSupportedColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *camtStatementTransactionDataTable) TransactionRowCount() int {
totalDataRowCount := 0
for i := 0; i < len(t.allStatements); i++ {
statement := t.allStatements[i]
for j := 0; j < len(statement.Entries); j++ {
entry := statement.Entries[j]
if entry.EntryDetails != nil {
totalDataRowCount += len(entry.EntryDetails.TransactionDetails)
} else {
totalDataRowCount++
}
}
}
return totalDataRowCount
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *camtStatementTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &camtStatementTransactionDataRowIterator{
dataTable: t,
currentStatementIndex: 0,
currentEntryIndex: 0,
currentTransactionDetailsIndex: -1,
}
}
// IsValid returns whether this row is valid data for importing
func (r *camtStatementTransactionDataRow) IsValid() bool {
return true
}
// GetData returns the data in the specified column type
func (r *camtStatementTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
_, exists := camtTransactionSupportedColumns[column]
if exists {
return r.finalItems[column]
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *camtStatementTransactionDataRowIterator) HasNext() bool {
allStatements := t.dataTable.allStatements
if t.currentStatementIndex >= len(allStatements) {
return false
}
currentStatement := allStatements[t.currentStatementIndex]
if t.currentEntryIndex+1 < len(currentStatement.Entries) {
return true
} else if t.currentEntryIndex < len(currentStatement.Entries) {
currencyEntry := currentStatement.Entries[t.currentEntryIndex]
if currencyEntry.EntryDetails != nil {
if t.currentTransactionDetailsIndex+1 < len(currencyEntry.EntryDetails.TransactionDetails) {
return true
}
} else {
if t.currentTransactionDetailsIndex < 0 {
return true
}
}
}
for i := t.currentStatementIndex + 1; i < len(allStatements); i++ {
statement := allStatements[i]
if len(statement.Entries) < 1 {
continue
}
return true
}
return false
}
// Next returns the next transaction data row
func (t *camtStatementTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
allStatements := t.dataTable.allStatements
for i := t.currentStatementIndex; i < len(allStatements); i++ {
foundNextRow := false
statement := allStatements[i]
for j := t.currentEntryIndex; j < len(statement.Entries); j++ {
if statement.Entries[j].EntryDetails != nil {
if t.currentTransactionDetailsIndex+1 < len(statement.Entries[j].EntryDetails.TransactionDetails) {
t.currentTransactionDetailsIndex++
foundNextRow = true
break
}
} else {
if t.currentTransactionDetailsIndex < 0 {
t.currentTransactionDetailsIndex++
foundNextRow = true
break
}
}
t.currentEntryIndex++
t.currentTransactionDetailsIndex = -1
}
if foundNextRow {
break
}
t.currentStatementIndex++
t.currentEntryIndex = 0
t.currentTransactionDetailsIndex = -1
}
if t.currentStatementIndex >= len(allStatements) {
return nil, nil
}
currentStatement := allStatements[t.currentStatementIndex]
if t.currentEntryIndex >= len(currentStatement.Entries) {
return nil, nil
}
account := currentStatement.Account
entry := currentStatement.Entries[t.currentEntryIndex]
var transactionDetails *camtTransactionDetails
if entry.EntryDetails != nil {
if t.currentTransactionDetailsIndex >= len(entry.EntryDetails.TransactionDetails) {
return nil, nil
} else {
transactionDetails = entry.EntryDetails.TransactionDetails[t.currentTransactionDetailsIndex]
}
} else {
if t.currentTransactionDetailsIndex >= 1 {
return nil, nil
}
}
rowItems, err := t.parseTransaction(ctx, user, account, entry, transactionDetails)
if err != nil {
log.Errorf(ctx, "[camt_statement_transaction_data_table.Next] cannot parsing transaction in entry#%d-transaction_detail#%d (statement#%d), because %s", t.currentEntryIndex, t.currentTransactionDetailsIndex, t.currentStatementIndex, err.Error())
return nil, err
}
return &camtStatementTransactionDataRow{
dataTable: t.dataTable,
account: account,
entry: entry,
transactionDetails: transactionDetails,
finalItems: rowItems,
}, nil
}
func (t *camtStatementTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, account *camtAccount, entry *camtEntry, transactionDetails *camtTransactionDetails) (map[datatable.TransactionDataTableColumn]string, error) {
data := make(map[datatable.TransactionDataTableColumn]string, len(camtTransactionSupportedColumns))
if account == nil {
return nil, errs.ErrMissingAccountData
}
if entry.BookingDate != nil && entry.BookingDate.DateTime != "" {
dateTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(entry.BookingDate.DateTime)
if err != nil {
return nil, 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())
} else if entry.BookingDate != nil && entry.BookingDate.Date != "" {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = fmt.Sprintf("%s 00:00:00", entry.BookingDate.Date)
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = datatable.TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE
} else {
return nil, errs.ErrMissingTransactionTime
}
if account.IBAN != "" {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = account.IBAN
} else if account.OtherIdentification != "" {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = account.OtherIdentification
}
if transactionDetails != nil && transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.TransactionAmount != nil && transactionDetails.AmountDetails.TransactionAmount.Currency != "" {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = transactionDetails.AmountDetails.TransactionAmount.Currency
} else if entry.Amount != nil && entry.Amount.Currency != "" {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = entry.Amount.Currency
} else if account.Currency != "" {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = account.Currency
} else {
return nil, errs.ErrAccountCurrencyInvalid
}
amountValue := ""
if entry.EntryDetails != nil && len(entry.EntryDetails.TransactionDetails) > 1 && transactionDetails != nil { // when there are multiple transaction details in one entry, only use the amount in the transaction details
if transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.InstructedAmount != nil && transactionDetails.AmountDetails.InstructedAmount.Value != "" {
amountValue = transactionDetails.AmountDetails.InstructedAmount.Value
} else if transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.TransactionAmount != nil && transactionDetails.AmountDetails.TransactionAmount.Value != "" {
amountValue = transactionDetails.AmountDetails.TransactionAmount.Value
} else {
return nil, errs.ErrAmountInvalid
}
} else if entry.Amount != nil && entry.Amount.Value != "" {
amountValue = entry.Amount.Value
}
if amountValue == "" {
return nil, errs.ErrAmountInvalid
}
amount, err := utils.ParseAmount(amountValue)
if err != nil {
log.Errorf(ctx, "[camt_statement_transaction_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", amountValue, err.Error())
return nil, errs.ErrAmountInvalid
}
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
if entry.CreditDebitIndicator == CAMT_INDICATOR_CREDIT {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME))
} else if entry.CreditDebitIndicator == CAMT_INDICATOR_DEBIT {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE))
} else {
return nil, errs.ErrTransactionTypeInvalid
}
if transactionDetails != nil && transactionDetails.AdditionalTransactionInformation != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = transactionDetails.AdditionalTransactionInformation
} else if transactionDetails != nil && transactionDetails.RemittanceInformation != nil && len(transactionDetails.RemittanceInformation.Unstructured) > 0 {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = strings.Join(transactionDetails.RemittanceInformation.Unstructured, "\n")
} else if entry.AdditionalEntryInformation != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = entry.AdditionalEntryInformation
} else {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
}
return data, nil
}
func createNewCamtStatementTransactionDataTable(file *camt053File) (*camtStatementTransactionDataTable, error) {
if file == nil || file.BankToCustomerStatement == nil || len(file.BankToCustomerStatement.Statements) == 0 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
return &camtStatementTransactionDataTable{
allStatements: file.BankToCustomerStatement.Statements,
}, nil
}
@@ -0,0 +1,48 @@
package camt
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var camtTransactionTypeNameMapping = 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)),
}
// camt053TransactionDataImporter defines the structure of camt.053 file importer for transaction data
type camt053TransactionDataImporter struct {
}
// Initialize a camt.053 transaction data importer singleton instance
var (
Camt053TransactionDataImporter = &camt053TransactionDataImporter{}
)
// ParseImportedData returns the imported data by parsing the camt.053 file transaction data
func (c *camt053TransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
camt053DataReader, err := createNewCamt053FileReader(data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
camt053Data, err := camt053DataReader.read(ctx)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable, err := createNewCamtStatementTransactionDataTable(camt053Data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(camtTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,765 @@
package camt
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 TestCamt053TransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
converter := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T01:23:45+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>DBIT</CdtDbtInd>
<Amt Ccy="CNY">0.12</Amt>
</Ntry>
</Stmt>
<Stmt>
<Acct>
<Id>
<Othr>
<Id>456</Id>
</Othr>
</Id>
<Ccy>USD</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T23:59:59+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="USD">1.23</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 3, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 0, 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_INCOME, allNewTransactions[2].Type)
assert.Equal(t, int64(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(123), allNewTransactions[2].Amount)
assert.Equal(t, "456", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "USD", allNewTransactions[2].OriginalSourceAccountCurrency)
assert.Equal(t, "", allNewTransactions[2].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, "456", allNewAccounts[1].Name)
assert.Equal(t, "USD", 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)
}
func TestCamt053TransactionDataFileParseImportedData_ParseValidTransactionTime(t *testing.T) {
converter := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<Dt>2024-09-01</Dt>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
<Ntry>
<BookgDt>
<DtTm>2024-09-02T03:04:05Z</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 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(1725165296), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(1725246245), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
}
func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime(t *testing.T) {
converter := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024T1</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01 12:34:56</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<Dt>2024/09/01</Dt>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmountAndCurrency(t *testing.T) {
converter := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="USD">123.45</Amt>
<NtryDtls>
<TxDtls>
<AmtDtls>
<TxAmt>
<Amt Ccy="USD">100.23</Amt>
</TxAmt>
</AmtDtls>
</TxDtls>
<TxDtls>
<AmtDtls>
<TxAmt>
<Amt Ccy="USD">23.22</Amt>
</TxAmt>
</AmtDtls>
</TxDtls>
</NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, int64(2322), allNewTransactions[0].Amount)
assert.Equal(t, "USD", allNewTransactions[1].OriginalSourceAccountCurrency)
assert.Equal(t, int64(10023), allNewTransactions[1].Amount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="USD">123.45</Amt>
<NtryDtls>
<TxDtls>
<AmtDtls>
<InstdAmt>
<Amt Ccy="USD">99.99</Amt>
</InstdAmt>
<TxAmt>
<Amt Ccy="USD">100.23</Amt>
</TxAmt>
</AmtDtls>
</TxDtls>
<TxDtls>
<AmtDtls>
<InstdAmt>
<Amt Ccy="USD">23.46</Amt>
</InstdAmt>
<TxAmt>
<Amt Ccy="USD">23.22</Amt>
</TxAmt>
</AmtDtls>
</TxDtls>
</NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, int64(2346), allNewTransactions[0].Amount)
assert.Equal(t, "USD", allNewTransactions[1].OriginalSourceAccountCurrency)
assert.Equal(t, int64(9999), allNewTransactions[1].Amount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="USD">123.45</Amt>
<NtryDtls>
<TxDtls>
<AmtDtls>
<TxAmt>
<Amt Ccy="USD">123.45</Amt>
</TxAmt>
</AmtDtls>
</TxDtls>
</NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt>123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "CNY", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
}
func TestCamt053TransactionDataFileParseImportedData_ParseTransactionInvalidAmountAndCurrency(t *testing.T) {
converter := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt>123 45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="USD">123.45</Amt>
<NtryDtls>
<TxDtls>
<AmtDtls>
</AmtDtls>
</TxDtls>
<TxDtls>
</TxDtls>
</NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="USD">123.45</Amt>
<NtryDtls>
<TxDtls>
</TxDtls>
<TxDtls>
<AmtDtls>
</AmtDtls>
</TxDtls>
</NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing.T) {
converter := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
<AddtlNtryInf>Test Entry</AddtlNtryInf>
<NtryDtls>
<TxDtls>
<AddtlTxInf>Test Transaction</AddtlTxInf>
<RmtInf>
<Ustrd>Test Line 1</Ustrd>
<Ustrd>Test Line 2</Ustrd>
</RmtInf>
</TxDtls>
</NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test Transaction", allNewTransactions[0].Comment)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
<AddtlNtryInf>Test Entry</AddtlNtryInf>
<NtryDtls>
<TxDtls>
<RmtInf>
<Ustrd>Test Line 1</Ustrd>
<Ustrd>Test Line 2</Ustrd>
</RmtInf>
</TxDtls>
</NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test Line 1\nTest Line 2", allNewTransactions[0].Comment)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
<AddtlNtryInf>Test Entry</AddtlNtryInf>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test Entry", allNewTransactions[0].Comment)
}
func TestCamt053TransactionDataFileParseImportedData_MissingAccountNode(t *testing.T) {
converter := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingAccountData.Message)
}
func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredNode(t *testing.T) {
converter := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingTransactionTime.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<Amt Ccy="CNY">123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
<Ccy>CNY</Ccy>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct>
<Id>
<IBAN>123</IBAN>
</Id>
</Acct>
<Ntry>
<BookgDt>
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
</BookgDt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Amt>123.45</Amt>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
@@ -0,0 +1,166 @@
package converter
import (
"fmt"
"strings"
"time"
"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"
)
// 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
}
// BuildExportedContent writes the exported transaction data to the data table builder
func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context, dataTableBuilder datatable.TransactionDataTableBuilder, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) error {
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
continue
}
dataRowMap := make(map[datatable.TransactionDataTableColumn]string, 15)
transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(transactionTimeZone)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataTableBuilder.ReplaceDelimiters(c.getDisplayTransactionTypeName(transaction.Type))
dataRowMap[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = c.getExportedTransactionCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = c.getExportedTransactionSubCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount)
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
}
dataRowMap[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = c.getExportedGeographicLocation(transaction)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TAGS] = c.getExportedTags(dataTableBuilder, transaction.TransactionId, allTagIndexes, tagMap)
dataRowMap[datatable.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 datatable.TransactionDataTableBuilder, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if !exists {
return ""
}
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
return dataTableBuilder.ReplaceDelimiters(category.Name)
}
parentCategory, exists := categoryMap[category.ParentCategoryId]
if !exists {
return ""
}
return dataTableBuilder.ReplaceDelimiters(parentCategory.Name)
}
func (c *DataTableTransactionDataExporter) getExportedTransactionSubCategoryName(dataTableBuilder datatable.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 datatable.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 datatable.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 datatable.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())
}
// 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,
}
}
@@ -0,0 +1,521 @@
package converter
import (
"sort"
"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"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
type TransactionGeoLocationOrder string
const (
TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE TransactionGeoLocationOrder = "lonlat" // longitude first, then latitude
TRANSACTION_GEO_LOCATION_ORDER_LATITUDE_LONGITUDE TransactionGeoLocationOrder = "latlon" // latitude first, then longitude
)
// DataTableTransactionDataImporter defines the structure of plain text data table importer for transaction data
type DataTableTransactionDataImporter struct {
transactionTypeMapping map[string]models.TransactionType
geoLocationSeparator string
geoLocationOrder TransactionGeoLocationOrder
transactionTagSeparator string
}
// ParseImportedData returns the imported transaction data
func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, dataTable datatable.TransactionDataTable, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]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_importer.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(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_AMOUNT) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME) {
log.Errorf(ctx, "[data_table_transaction_data_importer.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]map[string]*models.TransactionCategory)
}
if incomeCategoryMap == nil {
incomeCategoryMap = make(map[string]map[string]*models.TransactionCategory)
}
if transferCategoryMap == nil {
transferCategoryMap = make(map[string]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_importer.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(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) &&
dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) != datatable.TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE {
transactionTimezone, err := utils.ParseFromTimezoneOffset(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_importer.ParseImportedData] cannot parse time zone \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.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(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME), timezoneOffset)
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_importer.ParseImportedData] cannot parse time \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.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(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_importer.ParseImportedData] cannot parse transaction type \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.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)
categoryName := ""
subCategoryName := ""
if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionCategoryType, err := c.getTransactionCategoryType(transactionDbType)
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_importer.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)
}
categoryName = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_CATEGORY)
subCategoryName = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY)
if transactionDbType == models.TRANSACTION_DB_TYPE_EXPENSE {
subCategory, exists := c.getTransactionCategory(expenseCategoryMap, categoryName, subCategoryName)
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubExpenseCategories = append(allNewSubExpenseCategories, subCategory)
if _, exists = expenseCategoryMap[subCategoryName]; !exists {
expenseCategoryMap[subCategoryName] = make(map[string]*models.TransactionCategory)
}
expenseCategoryMap[subCategoryName][categoryName] = subCategory
}
categoryId = subCategory.CategoryId
} else if transactionDbType == models.TRANSACTION_DB_TYPE_INCOME {
subCategory, exists := c.getTransactionCategory(incomeCategoryMap, categoryName, subCategoryName)
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubIncomeCategories = append(allNewSubIncomeCategories, subCategory)
if _, exists = incomeCategoryMap[subCategoryName]; !exists {
incomeCategoryMap[subCategoryName] = make(map[string]*models.TransactionCategory)
}
incomeCategoryMap[subCategoryName][categoryName] = subCategory
}
categoryId = subCategory.CategoryId
} else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
subCategory, exists := c.getTransactionCategory(transferCategoryMap, categoryName, subCategoryName)
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubTransferCategories = append(allNewSubTransferCategories, subCategory)
if _, exists = transferCategoryMap[subCategoryName]; !exists {
transferCategoryMap[subCategoryName] = make(map[string]*models.TransactionCategory)
}
transferCategoryMap[subCategoryName][categoryName] = subCategory
}
categoryId = subCategory.CategoryId
}
}
accountName := dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
accountCurrency := user.DefaultCurrency
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY) && dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY) != "" {
accountCurrency = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY)
if _, ok := validators.AllCurrencyNames[accountCurrency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_importer.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(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY) && dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY) != "" {
if account.Name != "" && account.Currency != accountCurrency {
log.Errorf(ctx, "[data_table_transaction_data_importer.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(datatable.TRANSACTION_DATA_TABLE_AMOUNT))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_importer.ParseImportedData] cannot parse acmount \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.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(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME)
account2Currency = user.DefaultCurrency
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY) && dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY) != "" {
account2Currency = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY)
if _, ok := validators.AllCurrencyNames[account2Currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_importer.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(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY) && dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY) != "" {
if account2.Name != "" && account2.Currency != account2Currency {
log.Errorf(ctx, "[data_table_transaction_data_importer.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(datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT) {
relatedAccountAmount, err = utils.ParseAmount(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_importer.ParseImportedData] cannot parse acmount2 \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.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(datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) && c.geoLocationSeparator != "" {
geoLocationItems := strings.Split(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), c.geoLocationSeparator)
if len(geoLocationItems) == 2 {
geoLocationFirstItem, err := utils.StringToFloat64(geoLocationItems[0])
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_importer.ParseImportedData] cannot parse geographic location \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrGeographicLocationInvalid
}
geoLocationSecondItem, err := utils.StringToFloat64(geoLocationItems[1])
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_importer.ParseImportedData] cannot parse geographic location \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrGeographicLocationInvalid
}
if c.geoLocationOrder == TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE {
geoLongitude = geoLocationFirstItem
geoLatitude = geoLocationSecondItem
} else if c.geoLocationOrder == TRANSACTION_GEO_LOCATION_ORDER_LATITUDE_LONGITUDE {
geoLatitude = geoLocationFirstItem
geoLongitude = geoLocationSecondItem
}
}
}
var tagIds []string
var tagNames []string
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TAGS) {
var tagNameItems []string
if c.transactionTagSeparator != "" {
tagNameItems = strings.Split(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
} else {
tagNameItems = append(tagNameItems, dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TAGS))
}
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(datatable.TRANSACTION_DATA_TABLE_DESCRIPTION) {
description = dataRow.GetData(datatable.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_importer.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))
for name, transactionType := range c.transactionTypeMapping {
if transactionType == models.TRANSACTION_TYPE_MODIFY_BALANCE {
nameDbTypeMap[name] = models.TRANSACTION_DB_TYPE_MODIFY_BALANCE
} else if transactionType == models.TRANSACTION_TYPE_INCOME {
nameDbTypeMap[name] = models.TRANSACTION_DB_TYPE_INCOME
} else if transactionType == models.TRANSACTION_TYPE_EXPENSE {
nameDbTypeMap[name] = models.TRANSACTION_DB_TYPE_EXPENSE
} else if transactionType == models.TRANSACTION_TYPE_TRANSFER {
nameDbTypeMap[name] = models.TRANSACTION_DB_TYPE_TRANSFER_OUT
} else {
return nil, errs.ErrTransactionTypeInvalid
}
}
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) getTransactionCategory(categories map[string]map[string]*models.TransactionCategory, categoryName string, subCategoryName string) (*models.TransactionCategory, bool) {
if len(categories) < 1 {
return nil, false
}
subCategories, exists := categories[subCategoryName]
if !exists || len(subCategories) < 1 {
return nil, false
}
if categoryName == "" {
for _, subCategory := range subCategories {
if subCategory != nil {
return subCategory, true
}
}
}
subCategory, exists := subCategories[categoryName]
if !exists {
for _, subCategory := range subCategories {
if subCategory != nil {
return subCategory, true
}
}
}
return subCategory, exists
}
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,
}
}
// CreateNewImporterWithTypeNameMapping returns a new data table transaction data importer according to the specified arguments
func CreateNewImporterWithTypeNameMapping(transactionTypeMapping map[models.TransactionType]string, geoLocationSeparator string, geoLocationOrder TransactionGeoLocationOrder, transactionTagSeparator string) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
transactionTypeMapping: buildTransactionNameTypeMap(transactionTypeMapping),
geoLocationSeparator: geoLocationSeparator,
geoLocationOrder: geoLocationOrder,
transactionTagSeparator: transactionTagSeparator,
}
}
// CreateNewSimpleImporter returns a new data table transaction data importer according to the specified arguments
func CreateNewSimpleImporter(transactionTypeMapping map[string]models.TransactionType) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
transactionTypeMapping: transactionTypeMapping,
}
}
// CreateNewSimpleImporterWithTypeNameMapping returns a new data table transaction data importer according to the specified arguments
func CreateNewSimpleImporterWithTypeNameMapping(transactionTypeMapping map[models.TransactionType]string) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
transactionTypeMapping: buildTransactionNameTypeMap(transactionTypeMapping),
}
}
func buildTransactionNameTypeMap(transactionTypeMapping map[models.TransactionType]string) map[string]models.TransactionType {
if transactionTypeMapping == nil {
return nil
}
typeNameMap := make(map[string]models.TransactionType, len(transactionTypeMapping))
for transactionType, name := range transactionTypeMapping {
typeNameMap[name] = transactionType
}
return typeNameMap
}
@@ -1,4 +1,4 @@
package base
package converter
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
@@ -14,7 +14,7 @@ type TransactionDataExporter interface {
// 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)
ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]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
@@ -0,0 +1,155 @@
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"
)
// CsvFileBasicDataTable defines the structure of csv data table
type CsvFileBasicDataTable struct {
allLines [][]string
hasTitleLine bool
}
// CsvFileBasicDataTableRow defines the structure of csv data table row
type CsvFileBasicDataTableRow struct {
dataTable *CsvFileBasicDataTable
allItems []string
}
// CsvFileBasicDataTableRowIterator defines the structure of csv data table row iterator
type CsvFileBasicDataTableRowIterator struct {
dataTable *CsvFileBasicDataTable
currentIndex int
}
// DataRowCount returns the total count of data row
func (t *CsvFileBasicDataTable) DataRowCount() int {
if len(t.allLines) < 1 {
return 0
}
if t.hasTitleLine {
return len(t.allLines) - 1
} else {
return len(t.allLines)
}
}
// HeaderColumnNames returns the header column name list
func (t *CsvFileBasicDataTable) HeaderColumnNames() []string {
if len(t.allLines) < 1 {
return nil
}
if t.hasTitleLine {
return t.allLines[0]
} else {
return nil
}
}
// DataRowIterator returns the iterator of data row
func (t *CsvFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
startIndex := -1
if t.hasTitleLine {
startIndex = 0
}
return &CsvFileBasicDataTableRowIterator{
dataTable: t,
currentIndex: startIndex,
}
}
// ColumnCount returns the total count of column in this data row
func (r *CsvFileBasicDataTableRow) ColumnCount() int {
return len(r.allItems)
}
// GetData returns the data in the specified column index
func (r *CsvFileBasicDataTableRow) 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 *CsvFileBasicDataTableRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allLines)
}
// CurrentRowId returns current index
func (t *CsvFileBasicDataTableRowIterator) CurrentRowId() string {
return fmt.Sprintf("line#%d", t.currentIndex)
}
// Next returns the next basic data row
func (t *CsvFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
if t.currentIndex+1 >= len(t.dataTable.allLines) {
return nil
}
t.currentIndex++
rowItems := t.dataTable.allLines[t.currentIndex]
return &CsvFileBasicDataTableRow{
dataTable: t.dataTable,
allItems: rowItems,
}
}
// CreateNewCsvBasicDataTable returns comma separated values data table by io readers
func CreateNewCsvBasicDataTable(ctx core.Context, reader io.Reader, hasTitleLine bool) (datatable.BasicDataTable, error) {
return createNewCsvFileBasicDataTable(ctx, reader, ',', hasTitleLine)
}
// CreateNewCustomCsvBasicDataTable returns character separated values data table by io readers
func CreateNewCustomCsvBasicDataTable(allLines [][]string, hasTitleLine bool) datatable.BasicDataTable {
return &CsvFileBasicDataTable{
allLines: allLines,
hasTitleLine: hasTitleLine,
}
}
func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune, hasTitleLine bool) (*CsvFileBasicDataTable, 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_basic_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 &CsvFileBasicDataTable{
allLines: allLines,
hasTitleLine: hasTitleLine,
}, nil
}
@@ -9,52 +9,93 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/core"
)
func TestCsvFileImportedDataTableDataRowCount(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, false)
assert.Equal(t, 3, datatable.DataRowCount())
}
func TestCsvFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
}, true)
assert.Equal(t, 2, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
func TestCsvFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
})
}, true)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableDataRowCount_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{})
func TestCsvFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
assert.Equal(t, 0, datatable.DataRowCount())
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableHeaderColumnNames(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
func TestCsvFileBasicDataTableHeaderColumnNames(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, true)
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
}
func TestCsvFileImportedDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{})
func TestCsvFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
assert.Nil(t, datatable.HeaderColumnNames())
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
assert.Nil(t, datatable.HeaderColumnNames())
}
func TestCsvFileImportedDataRowIterator(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
func TestCsvFileBasicDataTableRowIterator(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, false)
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.True(t, iterator.HasNext())
// data row 3
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestCsvFileBasicDataTableRowIterator_HasTitleLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
}, true)
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
@@ -76,12 +117,12 @@ func TestCsvFileImportedDataRowIterator(t *testing.T) {
assert.False(t, iterator.HasNext())
}
func TestCsvFileImportedDataRowColumnCount(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
func TestCsvFileBasicDataTableRowColumnCount(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, true)
iterator := datatable.DataRowIterator()
@@ -92,12 +133,37 @@ func TestCsvFileImportedDataRowColumnCount(t *testing.T) {
assert.EqualValues(t, 3, row2.ColumnCount())
}
func TestCsvFileImportedDataRowGetData(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
func TestCsvFileBasicDataTableRowGetData(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, false)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A1", row1.GetData(0))
assert.Equal(t, "B1", row1.GetData(1))
assert.Equal(t, "C1", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A2", row2.GetData(0))
assert.Equal(t, "B2", row2.GetData(1))
assert.Equal(t, "C2", row2.GetData(2))
row3 := iterator.Next()
assert.Equal(t, "A3", row3.GetData(0))
assert.Equal(t, "B3", row3.GetData(1))
assert.Equal(t, "C3", row3.GetData(2))
}
func TestCsvFileBasicDataTableRowGetData_HasTitleLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
}, true)
iterator := datatable.DataRowIterator()
@@ -112,12 +178,12 @@ func TestCsvFileImportedDataRowGetData(t *testing.T) {
assert.Equal(t, "C3", row2.GetData(2))
}
func TestCsvFileImportedDataRowGetData_GetNotExistedColumnData(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
func TestCsvFileBasicDataTableRowGetData_GetNotExistedColumnData(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, true)
iterator := datatable.DataRowIterator()
@@ -125,12 +191,12 @@ func TestCsvFileImportedDataRowGetData_GetNotExistedColumnData(t *testing.T) {
assert.Equal(t, "", row1.GetData(3))
}
func TestCreateNewCsvImportedDataTable(t *testing.T) {
func TestCreateNewCsvBasicDataTable(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)
datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
@@ -153,14 +219,14 @@ func TestCreateNewCsvImportedDataTable(t *testing.T) {
assert.False(t, iterator.HasNext())
}
func TestCreateNewCsvImportedDataTable_SkipBlankLine(t *testing.T) {
func TestCreateNewCsvBasicDataTable_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)
datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
@@ -1,138 +0,0 @@
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
}
@@ -1,7 +1,7 @@
package datatable
// ImportedDataTable defines the structure of imported data table
type ImportedDataTable interface {
// BasicDataTable defines the structure of basic data table
type BasicDataTable interface {
// DataRowCount returns the total count of data row
DataRowCount() int
@@ -9,11 +9,11 @@ type ImportedDataTable interface {
HeaderColumnNames() []string
// DataRowIterator returns the iterator of data row
DataRowIterator() ImportedDataRowIterator
DataRowIterator() BasicDataTableRowIterator
}
// ImportedDataRow defines the structure of imported data row
type ImportedDataRow interface {
// BasicDataTableRow defines the structure of basic data row
type BasicDataTableRow interface {
// ColumnCount returns the total count of column in this data row
ColumnCount() int
@@ -21,14 +21,14 @@ type ImportedDataRow interface {
GetData(columnIndex int) string
}
// ImportedDataRowIterator defines the structure of imported data row iterator
type ImportedDataRowIterator interface {
// BasicDataTableRowIterator defines the structure of basic data row iterator
type BasicDataTableRowIterator 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
// Next returns the next basic data row
Next() BasicDataTableRow
}
@@ -0,0 +1,65 @@
package datatable
type testBasicDataTable struct {
headerColumns []string
rows []*testBasicDataTableRow
}
type testBasicDataTableRow struct {
rowId string
rowColumns []string
}
type testBasicDataTableRowIterator struct {
rows []*testBasicDataTableRow
currentIndex int
}
func (t *testBasicDataTable) HeaderColumnNames() []string {
return t.headerColumns
}
func (t *testBasicDataTable) DataRowCount() int {
return len(t.rows)
}
func (t *testBasicDataTable) DataRowIterator() BasicDataTableRowIterator {
return &testBasicDataTableRowIterator{
rows: t.rows,
currentIndex: -1,
}
}
func (r *testBasicDataTableRow) ColumnCount() int {
return len(r.rowColumns)
}
func (r *testBasicDataTableRow) GetData(columnIndex int) string {
if columnIndex < 0 || columnIndex >= len(r.rowColumns) {
return ""
}
return r.rowColumns[columnIndex]
}
func (t *testBasicDataTableRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.rows)
}
func (t *testBasicDataTableRowIterator) CurrentRowId() string {
if t.currentIndex >= len(t.rows) {
return ""
}
return t.rows[t.currentIndex].rowId
}
func (t *testBasicDataTableRowIterator) Next() BasicDataTableRow {
if t.currentIndex+1 >= len(t.rows) {
return nil
}
t.currentIndex++
row := t.rows[t.currentIndex]
return row
}
@@ -0,0 +1,107 @@
package datatable
// basicDataTableToCommonDataTableWrapper defines the structure of basic data table to common data table wrapper
type basicDataTableToCommonDataTableWrapper struct {
innerDataTable BasicDataTable
dataColumnIndexes map[string]int
}
// basicDataTableToCommonDataTableWrapperRow defines the data row structure of basic data table to common data table wrapper
type basicDataTableToCommonDataTableWrapperRow struct {
rowData map[string]string
}
// basicDataTableToCommonDataTableWrapperRowIterator defines the data row iterator structure of basic data table to common data table wrapper
type basicDataTableToCommonDataTableWrapperRowIterator struct {
commonDataTable *basicDataTableToCommonDataTableWrapper
innerIterator BasicDataTableRowIterator
}
// HeaderColumnCount returns the total count of column in header row
func (t *basicDataTableToCommonDataTableWrapper) HeaderColumnCount() int {
return len(t.innerDataTable.HeaderColumnNames())
}
// HasColumn returns whether the data table has specified column name
func (t *basicDataTableToCommonDataTableWrapper) HasColumn(columnName string) bool {
index, exists := t.dataColumnIndexes[columnName]
return exists && index >= 0
}
// DataRowCount returns the total count of common data row
func (t *basicDataTableToCommonDataTableWrapper) DataRowCount() int {
return t.innerDataTable.DataRowCount()
}
// DataRowIterator returns the iterator of common data row
func (t *basicDataTableToCommonDataTableWrapper) DataRowIterator() CommonDataTableRowIterator {
return &basicDataTableToCommonDataTableWrapperRowIterator{
commonDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// HasData returns whether the common data row has specified column data
func (r *basicDataTableToCommonDataTableWrapperRow) HasData(columnName string) bool {
_, exists := r.rowData[columnName]
return exists
}
// ColumnCount returns the total count of column in this data row
func (r *basicDataTableToCommonDataTableWrapperRow) ColumnCount() int {
return len(r.rowData)
}
// GetData returns the data in the specified column name
func (r *basicDataTableToCommonDataTableWrapperRow) GetData(columnName string) string {
return r.rowData[columnName]
}
// HasNext returns whether the iterator does not reach the end
func (t *basicDataTableToCommonDataTableWrapperRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// CurrentRowId returns current row id
func (t *basicDataTableToCommonDataTableWrapperRowIterator) CurrentRowId() string {
return t.innerIterator.CurrentRowId()
}
// Next returns the next common data row
func (t *basicDataTableToCommonDataTableWrapperRowIterator) Next() CommonDataTableRow {
basicDataRow := t.innerIterator.Next()
if basicDataRow == nil {
return nil
}
rowData := make(map[string]string, len(t.commonDataTable.dataColumnIndexes))
for column, columnIndex := range t.commonDataTable.dataColumnIndexes {
if columnIndex < 0 || columnIndex >= basicDataRow.ColumnCount() {
continue
}
value := basicDataRow.GetData(columnIndex)
rowData[column] = value
}
return &basicDataTableToCommonDataTableWrapperRow{
rowData: rowData,
}
}
// CreateNewCommonDataTableFromBasicDataTable returns common data table from basic data table
func CreateNewCommonDataTableFromBasicDataTable(dataTable BasicDataTable) CommonDataTable {
headerLineItems := dataTable.HeaderColumnNames()
dataColumnIndexes := make(map[string]int, len(headerLineItems))
for i := 0; i < len(headerLineItems); i++ {
dataColumnIndexes[headerLineItems[i]] = i
}
return &basicDataTableToCommonDataTableWrapper{
innerDataTable: dataTable,
dataColumnIndexes: dataColumnIndexes,
}
}
@@ -0,0 +1,108 @@
package datatable
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBasicDataTableToCommonDataTableWrapper_HeaderColumnCount(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: []*testBasicDataTableRow{},
}
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
assert.Equal(t, len(columns), commonDataTable.HeaderColumnCount())
}
func TestBasicDataTableToCommonDataTableWrapper_HasColumn(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: []*testBasicDataTableRow{},
}
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
assert.True(t, commonDataTable.HasColumn("Col1"))
assert.True(t, commonDataTable.HasColumn("Col2"))
assert.True(t, commonDataTable.HasColumn("Col3"))
assert.False(t, commonDataTable.HasColumn("Col4"))
assert.False(t, commonDataTable.HasColumn(""))
}
func TestBasicDataTableToCommonDataTableWrapper_DataRowCount(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
{
rowId: "3",
rowColumns: []string{"A3", "B3", "C3"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
assert.Equal(t, len(rows), commonDataTable.DataRowCount())
}
func TestBasicDataTableToCommonDataTableWrapper_DataRowIterator(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
iterator := commonDataTable.DataRowIterator()
assert.True(t, iterator.HasNext())
firstRow := iterator.Next()
assert.NotNil(t, firstRow)
assert.Equal(t, len(columns), firstRow.ColumnCount())
assert.True(t, firstRow.HasData("Col1"))
assert.True(t, firstRow.HasData("Col2"))
assert.True(t, firstRow.HasData("Col3"))
assert.Equal(t, "A1", firstRow.GetData("Col1"))
assert.Equal(t, "B1", firstRow.GetData("Col2"))
assert.Equal(t, "C1", firstRow.GetData("Col3"))
assert.True(t, iterator.HasNext())
secondRow := iterator.Next()
assert.NotNil(t, secondRow)
assert.Equal(t, len(columns), secondRow.ColumnCount())
assert.True(t, secondRow.HasData("Col1"))
assert.True(t, secondRow.HasData("Col2"))
assert.True(t, secondRow.HasData("Col3"))
assert.Equal(t, "A2", secondRow.GetData("Col1"))
assert.Equal(t, "B2", secondRow.GetData("Col2"))
assert.Equal(t, "C2", secondRow.GetData("Col3"))
assert.False(t, iterator.HasNext())
assert.Nil(t, iterator.Next())
}
@@ -7,30 +7,30 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// ImportedTransactionDataTable defines the structure of imported transaction data table
type ImportedTransactionDataTable struct {
innerDataTable ImportedDataTable
// basicDataTableToTransactionDataTableWrapper defines the structure of basic data table to transaction data table wrapper
type basicDataTableToTransactionDataTableWrapper struct {
innerDataTable BasicDataTable
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
// basicDataTableToTransactionDataTableWrapperRow defines the data row structure of basic data table to transaction data table wrapper
type basicDataTableToTransactionDataTableWrapperRow struct {
transactionDataTable *basicDataTableToTransactionDataTableWrapper
rowData map[TransactionDataTableColumn]string
rowDataValid bool
}
// ImportedTransactionDataRowIterator defines the structure of imported transaction data row iterator
type ImportedTransactionDataRowIterator struct {
transactionDataTable *ImportedTransactionDataTable
innerIterator ImportedDataRowIterator
// basicDataTableToTransactionDataTableWrapperRowIterator defines the data row iterator structure of basic data table to transaction data table wrapper
type basicDataTableToTransactionDataTableWrapperRowIterator struct {
transactionDataTable *basicDataTableToTransactionDataTableWrapper
innerIterator BasicDataTableRowIterator
}
// HasColumn returns whether the data table has specified column
func (t *ImportedTransactionDataTable) HasColumn(column TransactionDataTableColumn) bool {
func (t *basicDataTableToTransactionDataTableWrapper) HasColumn(column TransactionDataTableColumn) bool {
index, exists := t.dataColumnIndexes[column]
if exists && index >= 0 {
@@ -49,25 +49,25 @@ func (t *ImportedTransactionDataTable) HasColumn(column TransactionDataTableColu
}
// TransactionRowCount returns the total count of transaction data row
func (t *ImportedTransactionDataTable) TransactionRowCount() int {
func (t *basicDataTableToTransactionDataTableWrapper) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *ImportedTransactionDataTable) TransactionRowIterator() TransactionDataRowIterator {
return &ImportedTransactionDataRowIterator{
func (t *basicDataTableToTransactionDataTableWrapper) TransactionRowIterator() TransactionDataRowIterator {
return &basicDataTableToTransactionDataTableWrapperRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *ImportedTransactionDataRow) IsValid() bool {
func (r *basicDataTableToTransactionDataTableWrapperRow) IsValid() bool {
return r.rowDataValid
}
// GetData returns the data in the specified column type
func (r *ImportedTransactionDataRow) GetData(column TransactionDataTableColumn) string {
func (r *basicDataTableToTransactionDataTableWrapperRow) GetData(column TransactionDataTableColumn) string {
if !r.rowDataValid {
return ""
}
@@ -90,28 +90,28 @@ func (r *ImportedTransactionDataRow) GetData(column TransactionDataTableColumn)
}
// HasNext returns whether the iterator does not reach the end
func (t *ImportedTransactionDataRowIterator) HasNext() bool {
func (t *basicDataTableToTransactionDataTableWrapperRowIterator) 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()
func (t *basicDataTableToTransactionDataTableWrapperRowIterator) Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error) {
basicDataRow := t.innerIterator.Next()
if importedRow == nil {
if basicDataRow == nil {
return nil, nil
}
if importedRow.ColumnCount() == 1 && importedRow.GetData(0) == "" {
return &ImportedTransactionDataRow{
if basicDataRow.ColumnCount() == 1 && basicDataRow.GetData(0) == "" {
return &basicDataTableToTransactionDataTableWrapperRow{
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))
if basicDataRow.ColumnCount() < len(t.transactionDataTable.dataColumnIndexes) {
log.Errorf(ctx, "[basic_data_table_to_transaction_data_table_wrapper.Next] cannot parse data row, because may missing some columns (column count %d in data row is less than header column count %d)", basicDataRow.ColumnCount(), len(t.transactionDataTable.dataColumnIndexes))
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
@@ -119,11 +119,11 @@ func (t *ImportedTransactionDataRowIterator) Next(ctx core.Context, user *models
rowDataValid := true
for column, columnIndex := range t.transactionDataTable.dataColumnIndexes {
if columnIndex < 0 || columnIndex >= importedRow.ColumnCount() {
if columnIndex < 0 || columnIndex >= basicDataRow.ColumnCount() {
continue
}
value := importedRow.GetData(columnIndex)
value := basicDataRow.GetData(columnIndex)
rowData[column] = value
}
@@ -131,25 +131,25 @@ func (t *ImportedTransactionDataRowIterator) Next(ctx core.Context, user *models
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())
log.Errorf(ctx, "[basic_data_table_to_transaction_data_table_wrapper.Next] cannot parse data row, because %s", err.Error())
return nil, err
}
}
return &ImportedTransactionDataRow{
return &basicDataTableToTransactionDataTableWrapperRow{
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)
// CreateNewTransactionDataTableFromBasicDataTable returns transaction data table from basic data table
func CreateNewTransactionDataTableFromBasicDataTable(dataTable BasicDataTable, dataColumnMapping map[TransactionDataTableColumn]string) TransactionDataTable {
return CreateNewTransactionDataTableFromBasicDataTableWithRowParser(dataTable, dataColumnMapping, nil)
}
// CreateNewImportedTransactionDataTableWithRowParser returns transaction data table from imported data table
func CreateNewImportedTransactionDataTableWithRowParser(dataTable ImportedDataTable, dataColumnMapping map[TransactionDataTableColumn]string, rowParser TransactionDataRowParser) *ImportedTransactionDataTable {
// CreateNewTransactionDataTableFromBasicDataTableWithRowParser returns transaction data table from basic data table
func CreateNewTransactionDataTableFromBasicDataTableWithRowParser(dataTable BasicDataTable, dataColumnMapping map[TransactionDataTableColumn]string, rowParser TransactionDataRowParser) TransactionDataTable {
headerLineItems := dataTable.HeaderColumnNames()
headerItemMap := make(map[string]int, len(headerLineItems))
@@ -178,7 +178,7 @@ func CreateNewImportedTransactionDataTableWithRowParser(dataTable ImportedDataTa
}
}
return &ImportedTransactionDataTable{
return &basicDataTableToTransactionDataTableWrapper{
innerDataTable: dataTable,
dataColumnMapping: dataColumnMapping,
dataColumnIndexes: dataColumnIndexes,
@@ -0,0 +1,220 @@
package datatable
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
type testTransactionDataRowParser struct {
}
func (p *testTransactionDataRowParser) Parse(rowData map[TransactionDataTableColumn]string) (map[TransactionDataTableColumn]string, bool, error) {
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "Test Description"
return rowData, true, nil
}
func (p *testTransactionDataRowParser) GetAddedColumns() []TransactionDataTableColumn {
return []TransactionDataTableColumn{TRANSACTION_DATA_TABLE_DESCRIPTION}
}
func TestBasicDataTableToTransactionDataTableWrapper_HasColumn(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: []*testBasicDataTableRow{},
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_CATEGORY))
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowCount(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"2024-01-01", "1", "100"},
},
{
rowId: "2",
rowColumns: []string{"2024-01-02", "2", "200"},
},
{
rowId: "3",
rowColumns: []string{"2024-01-03", "1", "300"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
assert.Equal(t, len(rows), transactionDataTable.TransactionRowCount())
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"2024-01-01", "1", "100"},
},
{
rowId: "2",
rowColumns: []string{"2024-01-02", "2", "200"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
firstRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, firstRow)
assert.True(t, firstRow.IsValid())
assert.Equal(t, "2024-01-01", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.Equal(t, "1", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.Equal(t, "100", firstRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
assert.True(t, iterator.HasNext())
secondRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, secondRow)
assert.True(t, secondRow.IsValid())
assert.Equal(t, "2024-01-02", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.Equal(t, "2", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.Equal(t, "200", secondRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
assert.False(t, iterator.HasNext())
emptyRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.Nil(t, emptyRow)
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_EmptyRow(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{""},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
row, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, row)
assert.False(t, row.IsValid())
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_InvalidRow(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"2024-01-01", "1"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
row, err := iterator.Next(nil, nil)
assert.NotNil(t, err)
assert.Equal(t, errs.ErrFewerFieldsInDataRowThanInHeaderRow, err)
assert.Nil(t, row)
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_WithRowParserAddedColumn(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"2024-01-01", "1", "100"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
TRANSACTION_DATA_TABLE_DESCRIPTION: "Description",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTableWithRowParser(basicDataTable, columnMapping, &testTransactionDataRowParser{})
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
row, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, row)
assert.True(t, row.IsValid())
assert.Equal(t, "Test Description", row.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
}
@@ -12,11 +12,11 @@ type CommonDataTable interface {
DataRowCount() int
// DataRowIterator returns the iterator of common data row
DataRowIterator() CommonDataRowIterator
DataRowIterator() CommonDataTableRowIterator
}
// CommonDataRow defines the structure of common data row
type CommonDataRow interface {
// CommonDataTableRow defines the structure of common data row
type CommonDataTableRow interface {
// ColumnCount returns the total count of column in this data row
ColumnCount() int
@@ -27,8 +27,8 @@ type CommonDataRow interface {
GetData(columnName string) string
}
// CommonDataRowIterator defines the structure of common data row iterator
type CommonDataRowIterator interface {
// CommonDataTableRowIterator defines the structure of common data row iterator
type CommonDataTableRowIterator interface {
// HasNext returns whether the iterator does not reach the end
HasNext() bool
@@ -36,5 +36,5 @@ type CommonDataRowIterator interface {
CurrentRowId() string
// Next returns the next common data row
Next() CommonDataRow
Next() CommonDataTableRow
}
@@ -0,0 +1,109 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// 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, dataRow CommonDataTableRow, rowId string) (rowData map[TransactionDataTableColumn]string, rowDataValid bool, err error)
}
// commonDataTableToTransactionDataTableWrapper defines the structure of common data table to transaction data table wrapper
type commonDataTableToTransactionDataTableWrapper struct {
innerDataTable CommonDataTable
supportedDataColumns map[TransactionDataTableColumn]bool
rowParser CommonTransactionDataRowParser
}
// commonDataTableToTransactionDataTableWrapperRow defines the data row structure of common data table to transaction data table wrapper
type commonDataTableToTransactionDataTableWrapperRow struct {
transactionDataTable *commonDataTableToTransactionDataTableWrapper
rowData map[TransactionDataTableColumn]string
rowDataValid bool
}
// commonDataTableToTransactionDataTableWrapperRowIterator defines the data row iterator structure of common data table to transaction data table wrapper
type commonDataTableToTransactionDataTableWrapperRowIterator struct {
transactionDataTable *commonDataTableToTransactionDataTableWrapper
innerIterator CommonDataTableRowIterator
}
// HasColumn returns whether the data table has specified column
func (t *commonDataTableToTransactionDataTableWrapper) HasColumn(column TransactionDataTableColumn) bool {
_, exists := t.supportedDataColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *commonDataTableToTransactionDataTableWrapper) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *commonDataTableToTransactionDataTableWrapper) TransactionRowIterator() TransactionDataRowIterator {
return &commonDataTableToTransactionDataTableWrapperRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *commonDataTableToTransactionDataTableWrapperRow) IsValid() bool {
return r.rowDataValid
}
// GetData returns the data in the specified column type
func (r *commonDataTableToTransactionDataTableWrapperRow) 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 *commonDataTableToTransactionDataTableWrapperRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// Next returns the next transaction data row
func (t *commonDataTableToTransactionDataTableWrapperRowIterator) Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error) {
commonDataRow := t.innerIterator.Next()
if commonDataRow == nil {
return nil, nil
}
rowId := t.innerIterator.CurrentRowId()
rowData, rowDataValid, err := t.transactionDataTable.rowParser.Parse(ctx, user, commonDataRow, rowId)
if err != nil {
log.Errorf(ctx, "[common_data_table_to_transaction_data_table_wrapper.Next] cannot parse data row, because %s", err.Error())
return nil, err
}
return &commonDataTableToTransactionDataTableWrapperRow{
transactionDataTable: t.transactionDataTable,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// CreateNewTransactionDataTableFromCommonDataTable returns transaction data table from Common data table
func CreateNewTransactionDataTableFromCommonDataTable(dataTable CommonDataTable, supportedDataColumns map[TransactionDataTableColumn]bool, rowParser CommonTransactionDataRowParser) TransactionDataTable {
return &commonDataTableToTransactionDataTableWrapper{
innerDataTable: dataTable,
supportedDataColumns: supportedDataColumns,
rowParser: rowParser,
}
}
@@ -0,0 +1,258 @@
package datatable
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"
)
type testCommonDataTable struct {
headerColumns []string
dataRows []*testCommonDataTableRow
}
type testCommonDataTableRow struct {
rowId string
rowData map[string]string
}
type testCommonDataTableRowIterator struct {
dataTable *testCommonDataTable
currentIndex int
}
func (t *testCommonDataTable) DataRowCount() int {
return len(t.dataRows)
}
func (t *testCommonDataTable) HeaderColumnCount() int {
return len(t.headerColumns)
}
func (t *testCommonDataTable) HasColumn(columnName string) bool {
for _, header := range t.headerColumns {
if header == columnName {
return true
}
}
return false
}
func (t *testCommonDataTable) DataRowIterator() CommonDataTableRowIterator {
return &testCommonDataTableRowIterator{
dataTable: t,
currentIndex: -1,
}
}
func (t *testCommonDataTableRow) GetData(dataKey string) string {
return t.rowData[dataKey]
}
func (t *testCommonDataTableRow) HasData(dataKey string) bool {
_, exists := t.rowData[dataKey]
return exists
}
func (t *testCommonDataTableRow) ColumnCount() int {
return len(t.rowData)
}
func (t *testCommonDataTableRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.dataRows)
}
func (t *testCommonDataTableRowIterator) Next() CommonDataTableRow {
if !t.HasNext() {
return nil
}
t.currentIndex++
return t.dataTable.dataRows[t.currentIndex]
}
func (t *testCommonDataTableRowIterator) CurrentRowId() string {
if t.currentIndex < 0 || t.currentIndex >= len(t.dataTable.dataRows) {
return ""
}
return t.dataTable.dataRows[t.currentIndex].rowId
}
type testCommonTransactionDataRowParser struct {
returnError bool
}
func (p *testCommonTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow CommonDataTableRow, rowId string) (map[TransactionDataTableColumn]string, bool, error) {
if p.returnError {
return nil, false, errs.ErrOperationFailed
}
rowData := make(map[TransactionDataTableColumn]string)
rowData[TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData("TransactionTime")
rowData[TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData("TransactionType")
rowData[TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData("Amount")
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "Test Description"
return rowData, true, nil
}
func TestCommonDataTableToTransactionDataTableWrapper_HasColumn(t *testing.T) {
basicDataTable := &testCommonDataTable{
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
dataRows: []*testCommonDataTableRow{},
}
supportedColumns := map[TransactionDataTableColumn]bool{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
TRANSACTION_DATA_TABLE_AMOUNT: true,
}
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_CATEGORY))
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
}
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowCount(t *testing.T) {
rows := []*testCommonDataTableRow{
{
rowId: "1",
rowData: map[string]string{
"TransactionTime": "2024-01-01",
"TransactionType": "1",
"Amount": "100",
},
},
{
rowId: "2",
rowData: map[string]string{
"TransactionTime": "2024-01-02",
"TransactionType": "2",
"Amount": "200",
},
},
{
rowId: "3",
rowData: map[string]string{
"TransactionTime": "2024-01-03",
"TransactionType": "1",
"Amount": "300",
},
},
}
basicDataTable := &testCommonDataTable{
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
dataRows: rows,
}
supportedColumns := map[TransactionDataTableColumn]bool{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
TRANSACTION_DATA_TABLE_AMOUNT: true,
}
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
assert.Equal(t, len(rows), transactionDataTable.TransactionRowCount())
}
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowIterator(t *testing.T) {
rows := []*testCommonDataTableRow{
{
rowId: "1",
rowData: map[string]string{
"TransactionTime": "2024-01-01",
"TransactionType": "1",
"Amount": "100",
},
},
{
rowId: "2",
rowData: map[string]string{
"TransactionTime": "2024-01-02",
"TransactionType": "2",
"Amount": "200",
},
},
}
basicDataTable := &testCommonDataTable{
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
dataRows: rows,
}
supportedColumns := map[TransactionDataTableColumn]bool{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
TRANSACTION_DATA_TABLE_AMOUNT: true,
}
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
firstRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, firstRow)
assert.True(t, firstRow.IsValid())
assert.Equal(t, "2024-01-01", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.Equal(t, "1", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.Equal(t, "100", firstRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
assert.Equal(t, "", firstRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.True(t, iterator.HasNext())
secondRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, secondRow)
assert.True(t, secondRow.IsValid())
assert.Equal(t, "2024-01-02", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.Equal(t, "2", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.Equal(t, "200", secondRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
assert.Equal(t, "", secondRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.False(t, iterator.HasNext())
emptyRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.Nil(t, emptyRow)
}
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowIterator_EOF(t *testing.T) {
rows := []*testCommonDataTableRow{
{
rowId: "1",
rowData: map[string]string{
"TransactionTime": "2024-01-01",
"TransactionType": "1",
"Amount": "100",
},
},
}
basicDataTable := &testCommonDataTable{
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
dataRows: rows,
}
supportedColumns := map[TransactionDataTableColumn]bool{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
TRANSACTION_DATA_TABLE_AMOUNT: true,
}
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{returnError: true})
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
row, err := iterator.Next(nil, nil)
assert.EqualError(t, err, errs.ErrOperationFailed.Message)
assert.Nil(t, row)
}
@@ -1,114 +0,0 @@
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,
}
}
@@ -1,572 +0,0 @@
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,
}
}
@@ -1,107 +0,0 @@
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,87 @@
package datatable
// SubBasicDataTable defines the structure of sub basic data table
type SubBasicDataTable struct {
baseTable BasicDataTable
fromIndex int
toIndex int
}
// SubBasicDataTableRowIterator defines the structure of sub basic data table row iterator
type SubBasicDataTableRowIterator struct {
dataTable *SubBasicDataTable
innerIterator BasicDataTableRowIterator
currentIndex int
}
// DataRowCount returns the total count of data row
func (t *SubBasicDataTable) DataRowCount() int {
return t.toIndex - t.fromIndex
}
// HeaderColumnNames returns the header column name list
func (t *SubBasicDataTable) HeaderColumnNames() []string {
return t.baseTable.HeaderColumnNames()
}
// DataRowIterator returns the iterator of data row
func (t *SubBasicDataTable) DataRowIterator() BasicDataTableRowIterator {
innerIterator := t.baseTable.DataRowIterator()
currentIndex := -1
// skip rows until reaching the fromIndex
for currentIndex = -1; currentIndex < t.fromIndex-1 && innerIterator.HasNext(); currentIndex++ {
innerIterator.Next()
}
return &SubBasicDataTableRowIterator{
dataTable: t,
innerIterator: innerIterator,
currentIndex: currentIndex,
}
}
// HasNext returns whether the iterator does not reach the end
func (t *SubBasicDataTableRowIterator) HasNext() bool {
return t.currentIndex+1 < t.dataTable.toIndex && t.innerIterator.HasNext()
}
// CurrentRowId returns current row id
func (t *SubBasicDataTableRowIterator) CurrentRowId() string {
return t.innerIterator.CurrentRowId()
}
// Next returns the next basic data row
func (t *SubBasicDataTableRowIterator) Next() BasicDataTableRow {
if t.currentIndex+1 >= t.dataTable.toIndex {
return nil
}
t.currentIndex++
return t.innerIterator.Next()
}
// CreateSubBasicTable returns a sub basic data table that references a portion of the original table
func CreateSubBasicTable(dataTable BasicDataTable, fromIndex, toIndex int) *SubBasicDataTable {
if fromIndex < 0 {
fromIndex = 0
}
if fromIndex > dataTable.DataRowCount() {
fromIndex = dataTable.DataRowCount()
}
if toIndex > dataTable.DataRowCount() {
toIndex = dataTable.DataRowCount()
}
if toIndex < fromIndex {
toIndex = fromIndex
}
return &SubBasicDataTable{
baseTable: dataTable,
fromIndex: fromIndex,
toIndex: toIndex,
}
}
@@ -0,0 +1,140 @@
package datatable
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateSubBasicTable_WithValidInput(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
{
rowId: "3",
rowColumns: []string{"A3", "B3", "C3"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
subTable := CreateSubBasicTable(basicDataTable, 1, 2)
assert.Equal(t, 1, subTable.DataRowCount())
assert.Equal(t, columns, subTable.HeaderColumnNames())
}
func TestCreateSubBasicTable_WithInvalidInput(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
subTable := CreateSubBasicTable(basicDataTable, -1, 2)
assert.Equal(t, 0, subTable.fromIndex)
assert.Equal(t, 2, subTable.toIndex)
subTable = CreateSubBasicTable(basicDataTable, 5, 2)
assert.Equal(t, 2, subTable.fromIndex)
assert.Equal(t, 2, subTable.toIndex)
subTable = CreateSubBasicTable(basicDataTable, 0, 5)
assert.Equal(t, 0, subTable.fromIndex)
assert.Equal(t, 2, subTable.toIndex)
subTable = CreateSubBasicTable(basicDataTable, 2, 1)
assert.Equal(t, 2, subTable.fromIndex)
assert.Equal(t, 2, subTable.toIndex)
}
func TestSubBasicDataTable_DataRowIterator(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
{
rowId: "3",
rowColumns: []string{"A3", "B3", "C3"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
subTable := CreateSubBasicTable(basicDataTable, 1, 3)
iterator := subTable.DataRowIterator()
assert.True(t, iterator.HasNext())
firstRow := iterator.Next()
assert.NotNil(t, firstRow)
assert.Equal(t, "2", iterator.CurrentRowId())
assert.Equal(t, "A2", firstRow.GetData(0))
assert.Equal(t, "B2", firstRow.GetData(1))
assert.Equal(t, "C2", firstRow.GetData(2))
assert.True(t, iterator.HasNext())
secondRow := iterator.Next()
assert.NotNil(t, secondRow)
assert.Equal(t, "3", iterator.CurrentRowId())
assert.Equal(t, "A3", secondRow.GetData(0))
assert.Equal(t, "B3", secondRow.GetData(1))
assert.Equal(t, "C3", secondRow.GetData(2))
assert.False(t, iterator.HasNext())
assert.Nil(t, iterator.Next())
}
func TestSubBasicDataTable_EmptyDataRange(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
subTable := CreateSubBasicTable(basicDataTable, 1, 1)
assert.Equal(t, 0, subTable.DataRowCount())
iterator := subTable.DataRowIterator()
assert.False(t, iterator.HasNext())
assert.Nil(t, iterator.Next())
}
@@ -73,3 +73,6 @@ const (
TRANSACTION_DATA_TABLE_TAGS TransactionDataTableColumn = 13
TRANSACTION_DATA_TABLE_DESCRIPTION TransactionDataTableColumn = 14
)
// TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE represents the constant for timezone not available
const TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE = "TIMEZONE_NOT_AVAILABLE"
@@ -0,0 +1,96 @@
package _default
import (
"encoding/json"
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var allJsonDataSupportedColumns = []datatable.TransactionDataTableColumn{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME,
datatable.TRANSACTION_DATA_TABLE_AMOUNT,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME,
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT,
datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION,
datatable.TRANSACTION_DATA_TABLE_TAGS,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION,
}
// defaultTransactionDataJsonImporter defines the structure of ezbookkeeping default json importer for transaction data
type defaultTransactionDataJsonImporter struct{}
// Initialize an ezbookkeeping default transaction data json file importer singleton instance
var (
DefaultTransactionDataJsonFileImporter = &defaultTransactionDataJsonImporter{}
)
// ParseImportedData returns the imported data by parsing the transaction json data
func (c *defaultTransactionDataJsonImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
var importRequest models.ImportTransactionRequest
if err := json.Unmarshal(data, &importRequest); err != nil {
return nil, nil, nil, nil, nil, nil, errs.ErrInvalidJSONFile
}
transactionDataTable, err := c.createNewDefaultTransactionDataTable(importRequest)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(
ezbookkeepingTransactionTypeNameMapping,
ezbookkeepingGeoLocationSeparator,
ezbookkeepingGeoLocationOrder,
ezbookkeepingTagSeparator,
)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (c *defaultTransactionDataJsonImporter) createNewDefaultTransactionDataTable(importRequest models.ImportTransactionRequest) (datatable.TransactionDataTable, error) {
transactionDataTable := datatable.CreateNewWritableTransactionDataTable(allJsonDataSupportedColumns)
if importRequest.Transactions == nil || len(importRequest.Transactions) < 1 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
for i := 0; i < len(importRequest.Transactions); i++ {
transaction := importRequest.Transactions[i]
utcOffset, err := utils.StringToInt(transaction.UtcOffset)
if err != nil {
return nil, errs.ErrTransactionTimeZoneInvalid
}
timezone := time.FixedZone("Transaction Timezone", utcOffset*60)
row := make(map[datatable.TransactionDataTableColumn]string, len(allJsonDataSupportedColumns))
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = transaction.Time
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(timezone)
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = transaction.Type
row[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = transaction.CategoryName
row[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = transaction.SourceAccountName
row[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = transaction.SourceAmount
row[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = transaction.DestinationAccountName
row[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = transaction.DestinationAmount
row[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = transaction.GeoLocation
row[datatable.TRANSACTION_DATA_TABLE_TAGS] = transaction.TagNames
row[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = transaction.Comment
transactionDataTable.Add(row)
}
return transactionDataTable, nil
}
@@ -1,6 +1,7 @@
package _default
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -13,6 +14,7 @@ type defaultTransactionDataPlainTextConverter struct {
const ezbookkeepingLineSeparator = "\n"
const ezbookkeepingGeoLocationSeparator = " "
const ezbookkeepingGeoLocationOrder = converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE
const ezbookkeepingTagSeparator = ";"
var ezbookkeepingDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
@@ -66,7 +68,7 @@ func (c *defaultTransactionDataPlainTextConverter) ToExportedContent(ctx core.Co
ezbookkeepingLineSeparator,
)
dataTableExporter := datatable.CreateNewExporter(
dataTableExporter := converter.CreateNewExporter(
ezbookkeepingTransactionTypeNameMapping,
ezbookkeepingGeoLocationSeparator,
ezbookkeepingTagSeparator,
@@ -82,7 +84,7 @@ func (c *defaultTransactionDataPlainTextConverter) ToExportedContent(ctx core.Co
}
// 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) {
func (c *defaultTransactionDataPlainTextConverter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]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,
@@ -93,11 +95,12 @@ func (c *defaultTransactionDataPlainTextConverter) ParseImportedData(ctx core.Co
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable := datatable.CreateNewImportedTransactionDataTable(dataTable, ezbookkeepingDataColumnNameMapping)
transactionDataTable := datatable.CreateNewTransactionDataTableFromBasicDataTable(dataTable, ezbookkeepingDataColumnNameMapping)
dataTableImporter := datatable.CreateNewImporter(
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(
ezbookkeepingTransactionTypeNameMapping,
ezbookkeepingGeoLocationSeparator,
ezbookkeepingGeoLocationOrder,
ezbookkeepingTagSeparator,
)
@@ -52,7 +52,7 @@ func (t *defaultPlainTextDataTable) HeaderColumnNames() []string {
}
// DataRowIterator returns the iterator of data row
func (t *defaultPlainTextDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
func (t *defaultPlainTextDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
return &defaultPlainTextDataRowIterator{
dataTable: t,
currentIndex: 0,
@@ -83,8 +83,8 @@ 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 {
// Next returns the next basic data row
func (t *defaultPlainTextDataRowIterator) Next() datatable.BasicDataTableRow {
if t.currentIndex+1 >= len(t.dataTable.allLines) {
return nil
}
@@ -0,0 +1,236 @@
package dsv
import (
"bytes"
"encoding/csv"
"io"
"strings"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
csvconverter "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var supportedFileTypeSeparators = map[string]rune{
"custom_csv": ',',
"custom_tsv": '\t',
}
var supportedFileEncodings = map[string]encoding.Encoding{
"utf-8": unicode.UTF8, // UTF-8
"utf-8-bom": unicode.UTF8BOM, // UTF-8 with BOM
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), // UTF-16 Little Endian
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM), // UTF-16 Big Endian
"cp437": charmap.CodePage437, // OEM United States (CP-437)
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
"cp850": charmap.CodePage850, // Western European (CP-850)
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
"cp865": charmap.CodePage865, // North European (CP-865)
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
"cp852": charmap.CodePage852, // Central European (CP-852)
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
"cp860": charmap.CodePage860, // Portuguese (CP-860)
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
"cp862": charmap.CodePage862, // Hebrew (CP-862)
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
"windows-874": charmap.Windows874, // Thai (Windows-874)
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
}
var customTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)),
models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)),
models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)),
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
}
type CustomTransactionDataDsvFileParser interface {
ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error)
}
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
type customTransactionDataDsvFileImporter struct {
fileEncoding encoding.Encoding
separator rune
columnIndexMapping map[datatable.TransactionDataTableColumn]int
transactionTypeNameMapping map[string]models.TransactionType
hasHeaderLine bool
timeFormat string
timezoneFormat string
amountDecimalSeparator string
amountDigitGroupingSymbol string
geoLocationSeparator string
geoLocationOrder converter.TransactionGeoLocationOrder
transactionTagSeparator string
}
// ParseDsvFileLines returns the parsed file lines for specified the dsv file data
func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error) {
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
csvReader := csv.NewReader(reader)
csvReader.Comma = c.separator
csvReader.FieldsPerRecord = -1
allLines := make([][]string, 0)
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDsvFileLines] cannot parse dsv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if len(items) == 1 && items[0] == "" {
continue
}
for index := range items {
items[index] = strings.Trim(items[index], " ")
}
allLines = append(allLines, items)
}
return allLines, nil
}
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
allLines, err := c.ParseDsvFileLines(ctx, data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines, c.hasHeaderLine)
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol)
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.geoLocationOrder, c.transactionTagSeparator)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
// IsDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
func IsDelimiterSeparatedValuesFileType(fileType string) bool {
_, exists := supportedFileTypeSeparators[fileType]
return exists
}
// CreateNewCustomTransactionDataDsvFileParser returns a new custom dsv parser for transaction data
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataDsvFileParser, error) {
separator, exists := supportedFileTypeSeparators[fileType]
if !exists {
return nil, errs.ErrImportFileTypeNotSupported
}
enc, exists := supportedFileEncodings[fileEncoding]
if !exists {
return nil, errs.ErrImportFileEncodingNotSupported
}
return &customTransactionDataDsvFileImporter{
fileEncoding: enc,
separator: separator,
}, nil
}
// CreateNewCustomTransactionDataDsvFileImporter returns a new custom dsv importer for transaction data
func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
separator, exists := supportedFileTypeSeparators[fileType]
if !exists {
return nil, errs.ErrImportFileTypeNotSupported
}
enc, exists := supportedFileEncodings[fileEncoding]
if !exists {
return nil, errs.ErrImportFileEncodingNotSupported
}
if geoLocationOrder == "" {
geoLocationOrder = string(converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE)
} else if geoLocationOrder != string(converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE) &&
geoLocationOrder != string(converter.TRANSACTION_GEO_LOCATION_ORDER_LATITUDE_LONGITUDE) {
return nil, errs.ErrImportFileTypeNotSupported
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_AMOUNT]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
return &customTransactionDataDsvFileImporter{
fileEncoding: enc,
separator: separator,
columnIndexMapping: columnIndexMapping,
transactionTypeNameMapping: transactionTypeNameMapping,
hasHeaderLine: hasHeaderLine,
timeFormat: timeFormat,
timezoneFormat: timezoneFormat,
amountDecimalSeparator: amountDecimalSeparator,
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
geoLocationSeparator: geoLocationSeparator,
geoLocationOrder: converter.TransactionGeoLocationOrder(geoLocationOrder),
transactionTagSeparator: transactionTagSeparator,
}, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
package dsv
import (
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// customPlainTextDataTable defines the structure of custom plain text transaction data table
type customPlainTextDataTable struct {
innerDataTable datatable.BasicDataTable
columnIndexMapping map[datatable.TransactionDataTableColumn]int
transactionTypeNameMapping map[string]models.TransactionType
timeFormat string
timezoneFormat string
timeFormatIncludeTimezone bool
amountDecimalSeparator string
amountDigitGroupingSymbol string
}
// customPlainTextDataRow defines the structure of custom plain text transaction data row
type customPlainTextDataRow struct {
transactionDataTable *customPlainTextDataTable
rowData map[datatable.TransactionDataTableColumn]string
isValid bool
}
// customPlainTextDataRowIterator defines the structure of custom plain text transaction data row iterator
type customPlainTextDataRowIterator struct {
transactionDataTable *customPlainTextDataTable
innerIterator datatable.BasicDataTableRowIterator
}
// HasColumn returns whether the data table has specified column
func (t *customPlainTextDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
// custom dsv file allows no sub category, account name and related account name column mapping, but data table converter needs these columns
if column == datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY ||
column == datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME ||
column == datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME {
return true
}
// timezone column will be added when original time format contains timezone
if t.timeFormatIncludeTimezone && column == datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE {
return true
}
_, exists := t.columnIndexMapping[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *customPlainTextDataTable) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *customPlainTextDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &customPlainTextDataRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *customPlainTextDataRow) IsValid() bool {
return r.isValid
}
// GetData returns the data in the specified column type
func (r *customPlainTextDataRow) GetData(column datatable.TransactionDataTableColumn) string {
return r.rowData[column]
}
// HasNext returns whether the iterator does not reach the end
func (t *customPlainTextDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// Next returns the next transaction data row
func (t *customPlainTextDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
importedRow := t.innerIterator.Next()
if importedRow == nil {
return nil, nil
}
rowData, isValid, err := t.parseTransaction(ctx, user, importedRow)
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.Next] cannot parsing transaction in row \"%s\", because %s", t.innerIterator.CurrentRowId(), err.Error())
return nil, err
}
return &customPlainTextDataRow{
transactionDataTable: t.transactionDataTable,
rowData: rowData,
isValid: isValid,
}, nil
}
func (t *customPlainTextDataRowIterator) parseTransaction(ctx core.Context, user *models.User, row datatable.BasicDataTableRow) (map[datatable.TransactionDataTableColumn]string, bool, error) {
rowData := make(map[datatable.TransactionDataTableColumn]string, len(t.transactionDataTable.columnIndexMapping))
for column, columnIndex := range t.transactionDataTable.columnIndexMapping {
if columnIndex < 0 || columnIndex >= row.ColumnCount() {
continue
}
value := row.GetData(columnIndex)
rowData[column] = value
}
// parse transaction type
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] != "" {
transactionType, exists := t.transactionDataTable.transactionTypeNameMapping[rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]]
if !exists {
log.Warnf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] skip parsing this transaction, because transaction type \"%s\" mapping not defined", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE])
return nil, false, nil
}
mappedTransactionType, exists := customTransactionTypeNameMapping[transactionType]
if !exists {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction type \"%s\", because type \"%d\" is invalid", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE], transactionType)
return nil, false, errs.ErrTransactionTypeInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = mappedTransactionType
}
// parse date time
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
dateTime, err := time.Parse(t.transactionDataTable.timeFormat, rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
if err != nil {
return nil, false, errs.ErrTransactionTimeInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
if t.transactionDataTable.timeFormatIncludeTimezone {
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
}
}
// parse timezone
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] != "" {
if t.transactionDataTable.timezoneFormat == "Z" || t.transactionDataTable.timezoneFormat == "" { // -HH:mm
// Do Nothing
} else if t.transactionDataTable.timezoneFormat == "ZZ" { // -HHmm
timezone := rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE]
if len(timezone) != 5 {
return nil, false, errs.ErrTransactionTimeZoneInvalid
}
timezone = timezone[:3] + ":" + timezone[3:]
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = timezone
} else {
return nil, false, errs.ErrImportFileTransactionTimezoneFormatInvalid
}
}
// use primary category if sub category is empty
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" && rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY] != "" {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY]
}
// trim trailing zero in decimal
if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" {
amount, err := t.parseAmount(ctx, rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT], err.Error())
return nil, false, err
}
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = amount
}
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" {
amount, err := t.parseAmount(ctx, rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction related amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT], err.Error())
return nil, false, err
}
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = amount
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
return rowData, true, nil
}
func (t *customPlainTextDataRowIterator) parseAmount(ctx core.Context, amountValue string) (string, error) {
if t.transactionDataTable.amountDigitGroupingSymbol != "" {
amountValue = strings.ReplaceAll(amountValue, t.transactionDataTable.amountDigitGroupingSymbol, "")
}
if t.transactionDataTable.amountDecimalSeparator != "" && t.transactionDataTable.amountDecimalSeparator != "." {
if strings.Contains(amountValue, ".") {
return "", errs.ErrAmountInvalid
}
amountValue = strings.ReplaceAll(amountValue, t.transactionDataTable.amountDecimalSeparator, ".")
}
amountValue = utils.TrimTrailingZerosInDecimal(amountValue)
amount, err := utils.ParseAmount(amountValue)
if err != nil {
return "", errs.ErrAmountInvalid
}
return utils.FormatAmount(amount), nil
}
// CreateNewCustomPlainTextDataTable returns transaction data table from imported data table
func CreateNewCustomPlainTextDataTable(dataTable datatable.BasicDataTable, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string) *customPlainTextDataTable {
timeFormatIncludeTimezone := strings.Contains(timeFormat, "z") || strings.Contains(timeFormat, "Z")
return &customPlainTextDataTable{
innerDataTable: dataTable,
columnIndexMapping: columnIndexMapping,
transactionTypeNameMapping: transactionTypeNameMapping,
timeFormat: getDateTimeFormat(timeFormat),
timezoneFormat: timezoneFormat,
timeFormatIncludeTimezone: timeFormatIncludeTimezone,
amountDecimalSeparator: amountDecimalSeparator,
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
}
}
func getDateTimeFormat(format string) string {
// convert moment.js format to Go format
format = strings.ReplaceAll(format, "YYYY", "2006")
format = strings.ReplaceAll(format, "YY", "06")
format = strings.ReplaceAll(format, "MMMM", "January")
format = strings.ReplaceAll(format, "MMM", "Jan")
format = strings.ReplaceAll(format, "MM", "01")
format = strings.ReplaceAll(format, "M", "1")
format = strings.ReplaceAll(format, "DD", "02")
format = strings.ReplaceAll(format, "D", "2")
format = strings.ReplaceAll(format, "dddd", "Monday")
format = strings.ReplaceAll(format, "ddd", "Mon")
format = strings.ReplaceAll(format, "HH", "15")
format = strings.ReplaceAll(format, "H", "15")
format = strings.ReplaceAll(format, "hh", "03")
format = strings.ReplaceAll(format, "h", "3")
format = strings.ReplaceAll(format, "mm", "04")
format = strings.ReplaceAll(format, "m", "4")
format = strings.ReplaceAll(format, "ss", "05")
format = strings.ReplaceAll(format, "s", "5")
for i := 9; i >= 1; i-- {
format = strings.ReplaceAll(format, "."+strings.Repeat("S", i), "."+strings.Repeat("9", i))
}
format = strings.ReplaceAll(format, "A", "PM")
format = strings.ReplaceAll(format, "a", "pm")
format = strings.ReplaceAll(format, "zz", "MST")
format = strings.ReplaceAll(format, "z", "MST")
if strings.Contains(format, "ZZ") {
format = strings.ReplaceAll(format, "ZZ", "Z0700")
} else if strings.Contains(format, "Z") {
format = strings.ReplaceAll(format, "Z", "Z07:00")
}
return format
}

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