Compare commits

..

569 Commits

Author SHA1 Message Date
MaysWind ab38d33e31 remove deprecated International Monetary Fund exchange rate data source 2026-01-31 00:30:55 +08:00
MaysWind 0020f4ede9 support importing camt.052 bank statement file 2026-01-31 00:20:40 +08:00
MaysWind b470cb63b7 replace ambiguous names with clearer names 2026-01-30 23:35:37 +08:00
MaysWind 32f2eaef3c when pasting date time, if multiple formats match and one matches the current display order, use that format for parsing 2026-01-30 22:40:41 +08:00
MaysWind a7fc3c78eb fix incorrect style 2026-01-30 21:25:28 +08:00
MaysWind d42b3ecb5e fix the continue button was missing during two-factor authentication on the desktop version when both two-factor authentication and third-party login are enabled both 2026-01-30 21:05:45 +08:00
MaysWind 2d4a603d11 automatically focus to the passcode input when a passcode is required 2026-01-30 21:02:54 +08:00
MaysWind 7a369328b6 support date formats that use dashes as separators and date formats without leading zeros 2026-01-26 23:59:37 +08:00
MaysWind 545667e502 update the text display style for AI image recognition on the desktop version 2026-01-25 21:15:21 +08:00
MaysWind 8387a81a59 fix the incorrect line-wrapping of the third-pary login separator line on the login page in the Korean locale 2026-01-24 23:55:14 +08:00
MaysWind 1e3087ccf0 bump version to 1.3.2 2026-01-24 23:49:20 +08:00
MaysWind bee7772bfd add refresh accounts, categories and tags button on import dialog 2026-01-24 23:21:02 +08:00
MaysWind a8b6f72ee6 update translation 2026-01-24 23:02:33 +08:00
MaysWind 9484cf514d show more accurate status messages when reloading all account, category and tag data 2026-01-24 22:53:56 +08:00
MaysWind 70958c00d3 limit the maximum height of the add button dropdown menu on the desktop transaction list page 2026-01-24 22:06:45 +08:00
MaysWind 9467335536 hide change tag group display order button when there are fewer than two tag groups 2026-01-24 21:57:42 +08:00
MaysWind f916fdff06 disable the move tag button when there are fewer than two tag groups 2026-01-24 21:55:45 +08:00
MaysWind ced346506e format translation file 2026-01-24 21:44:16 +08:00
MaysWind e0cd96f87e format code 2026-01-24 21:41:44 +08:00
Minhyung Park ed7e906903 Update Korean translation 2026-01-24 21:41:22 +08:00
MaysWind 3bb7f5abf4 support exporting data when checking pending import data 2026-01-22 23:58:04 +08:00
MaysWind 5d801a2343 remove redundant code 2026-01-22 23:15:36 +08:00
MaysWind 0d9e59dad9 fix the import dialog sometimes fails to automatically remember the last selected type 2026-01-22 22:23:05 +08:00
MaysWind 5fd1396b5c fix wrong mime type 2026-01-22 22:02:52 +08:00
MaysWind b3b9d9293b support semicolon-separated data when importing delimiter-separated values files / data (#458) 2026-01-22 22:01:59 +08:00
MaysWind 8b405e513f add new contributor 2026-01-22 00:00:17 +08:00
MaysWind 2bfcfbf03d update translation 2026-01-21 23:59:12 +08:00
MaysWind 10388a5ffa improved Chinese translation 2026-01-21 23:33:41 +08:00
MaysWind a127a381cc remove unused entries 2026-01-21 23:33:15 +08:00
MaysWind 4aa0dc20af update translation 2026-01-21 23:32:40 +08:00
Diego Fernández Criado 012cc04107 Update ES locales 2026-01-21 23:31:33 +08:00
MaysWind 25a84ad3af add new contributor 2026-01-21 22:30:29 +08:00
MaysWind c0036d230a update locale default settings 2026-01-21 22:30:03 +08:00
Harsh Vardan 869970a4ab refactor(locale): remove wrongfully formatted fsl translation file by ide 2026-01-21 11:06:02 +08:00
Harsh Vardan 42f8aa410c refactor(locale): proper import order in locales index.ts file 2026-01-21 11:06:02 +08:00
Harsh Vardan 80e1223505 feat(locale): add Tamil translation 2026-01-21 11:06:02 +08:00
MaysWind fc9581580c fix the system paste button appears again after the user tap outside 2026-01-19 22:23:24 +08:00
MaysWind b0e6764bfe do not allow switching tag groups before saving changes to the tag order 2026-01-19 01:02:46 +08:00
MaysWind 03fef81414 fix no error message is shown when a tag cannot be moved 2026-01-19 00:56:07 +08:00
MaysWind 8dcb8648a5 support tag group for transaction tags in the import transaction tool 2026-01-19 00:55:34 +08:00
MaysWind 50b4c96a99 fix the placeholder values in "root_url" cannot be resolved using values from environment variables, and do not allow placeholders in other options 2026-01-19 00:29:08 +08:00
MaysWind c9b894fdbe add new contributor 2026-01-18 13:42:11 +08:00
Andres Teller a2f1d944ad fix: correct typo in Spanish translation 2026-01-18 13:32:53 +08:00
MaysWind be4ec2bcce bump version to 1.3.1 2026-01-18 13:26:27 +08:00
MaysWind c1a728c391 add default value for newly added columns 2026-01-17 23:43:32 +08:00
MaysWind 46ff0ecd3b place the account at the end of the account category after changing account category 2026-01-17 23:32:13 +08:00
MaysWind 8db69f64c8 place the transaction category at the end of the primary category after changing primary category 2026-01-17 23:31:48 +08:00
MaysWind 8447dd7ae6 support pasting amount on number pad sheet for non-ios device 2026-01-17 22:16:30 +08:00
MaysWind 543cc5f656 disable buttons in navigation bar during initial page load 2026-01-17 20:52:48 +08:00
MaysWind 9664bac47f update the title of the change display order dialog/page 2026-01-17 20:12:43 +08:00
MaysWind 42ae323568 support adding / renaming / deleting / changing display order for tag group on mobile version 2026-01-17 20:04:07 +08:00
MaysWind a357fb8136 automatically switch to the newly added tag group 2026-01-17 19:24:09 +08:00
MaysWind 3b487ca0d9 fix the currently displayed group is incorrect after deleting a tag group 2026-01-17 19:02:44 +08:00
MaysWind 91e98f3126 make delete tag group button disabled when tag group has tags 2026-01-17 18:55:32 +08:00
MaysWind 7ecacaeb05 support moving tags on mobile version 2026-01-17 18:48:45 +08:00
MaysWind 598ae9fa06 show add button in default group 2026-01-17 16:05:04 +08:00
MaysWind 0803a5930f add new contributor 2026-01-17 14:29:46 +08:00
MaysWind 9aaf3284c0 remove unnecessary type assertions 2026-01-17 14:29:36 +08:00
GaryOu b27f9c12de refactor: use isDefined util for account ID check in TransactionEditPageBase.ts 2026-01-17 14:24:07 +08:00
GaryOu a730ebab8f Refactor transfer amount calculation to handle account changes
- Refactor `setTransactionSuitableDestinationAmount` in transactions store to handle account ID changes and avoid logic duplication.
- Recalculate transfer-in amount when changing accounts, while preserving manual edits by verifying previous exchange rate relationships.
- Clean up redundant calculation logic in `TransactionEditPageBase.ts`.
2026-01-17 14:24:07 +08:00
GaryOu f7d0e2279a Watch for account changes and recalculate destination amount for transfers on the Add Transfer Transaction dialog 2026-01-17 14:24:07 +08:00
MaysWind e41dd1c1f8 add new contributor 2026-01-17 10:25:02 +08:00
MaysWind 98274ab864 Merge branch 'main' of https://github.com/mayswind/EasyBookkeeping 2026-01-17 10:23:18 +08:00
lucdsouza a3261acc82 Fix: missing hyphen in 'utf-8' encoding. Error when importing OFX files due to unknown encoding. Fixes issue #48. 2026-01-17 10:13:29 +08:00
MaysWind 7d9cfc4ced support transaction tag group 2026-01-17 00:52:02 +08:00
MaysWind b556efa510 adjust the interaction for displaying and reordering all explorers on the Insights Explorer page 2026-01-17 00:45:09 +08:00
MaysWind 4b72bfd76d fix the changes are cleared after changing date range under "New Explorer" 2026-01-16 23:55:50 +08:00
MaysWind 0f532094ca update the initial styling of the pie chart 2026-01-16 23:30:28 +08:00
MaysWind 7e48cca4ab fix the non-amount numbers in charts are not formatted using localized number formatting 2026-01-16 23:29:26 +08:00
MaysWind 98aa535193 fix the year–quarter format date is not formatted using localized number formatting 2026-01-16 23:29:15 +08:00
MaysWind 48ef9acc19 support the username returned by Synology DSM SSO Server during OIDC authentication (#449) 2026-01-16 21:59:05 +08:00
MaysWind e304f4d3fa display all data in statistics & analysis and hide percentages with values below zero 2026-01-16 01:26:06 +08:00
MaysWind 83a34ae322 allow the either username or email is empty which returns from oauth 2.0 provider, but require both to be present when automatically registering a new user 2026-01-16 00:01:48 +08:00
MaysWind 43a6d1be0f initialize the http transport only once 2026-01-15 23:38:52 +08:00
MaysWind 89fb8a099e add a unified logging handler to the http client 2026-01-15 23:29:48 +08:00
MaysWind 853b0d430e update the supported currencies based on the exchange rate data source 2026-01-15 22:38:53 +08:00
Andrej Kralj 88b63d0222 Updated Slovenian translation 2026-01-14 20:12:45 +08:00
MaysWind 618ad4cac2 support importing Alipay transaction statements with transactions in the pending goods receipt confirmation status (#441) 2026-01-11 14:57:37 +08:00
MaysWind 9b4dd5600a show insights explorer count on data management page 2026-01-11 14:24:38 +08:00
MaysWind ca959fb9ce automatically focus after opening the dialog and support confirming with the enter key 2026-01-11 14:01:14 +08:00
MaysWind ee9b281919 insights explorer supports axis chart 2026-01-11 02:53:37 +08:00
MaysWind 1a0630846d refactor the trends chart component and extract a reusable axis chart component 2026-01-11 00:11:36 +08:00
MaysWind 9585cbc8a9 fix the explorer is not selected when opening a hidden explorer on the insights explorer page 2026-01-10 01:00:04 +08:00
MaysWind 19c0ca8191 add new contributor 2026-01-10 00:33:15 +08:00
Dmitry Shemin 3b0b95ac4a fix: trim trailling spaces in username 2026-01-10 00:32:03 +08:00
MaysWind 1691c320cc update json schema description of mcp tool 2026-01-10 00:16:04 +08:00
thehijacker caf88a9488 Updated Slovenian translation 2026-01-09 23:07:00 +08:00
MaysWind b295b99d3d update the supported currencies based on the exchange rate data source 2026-01-09 00:37:11 +08:00
MaysWind 3cf1276fa7 add all explorers dialog and show confirm dialog when restoring to last saved explorer 2026-01-09 00:33:31 +08:00
MaysWind 5ae763273a format code 2026-01-08 23:41:33 +08:00
MaysWind e39965e7b5 add restore to last saved for insights explorer 2026-01-08 01:29:54 +08:00
MaysWind af36fe9212 highlight the save button when the explorer has been updated 2026-01-08 01:20:30 +08:00
MaysWind 6eb7fa27f6 support configuring the data source of the data table in insights explorer 2026-01-08 00:43:27 +08:00
MaysWind 0dd0597c3b code refactor 2026-01-08 00:05:07 +08:00
MaysWind f0a74a6108 save the number of transactions per page in database 2026-01-07 23:56:31 +08:00
MaysWind 6829eddde5 display different dialog titles when saving a new explorer and renaming an explorer 2026-01-07 23:47:14 +08:00
MaysWind 1c596c4a15 support hiding and unhiding explorers 2026-01-07 23:39:07 +08:00
MaysWind ab88b0bf44 support drag-and-drop to change query display orders 2026-01-07 23:08:30 +08:00
MaysWind d462d0164c save insights explorer to database 2026-01-07 01:04:54 +08:00
MaysWind d4d1342c70 update the supported currencies based on the exchange rate data source 2026-01-05 23:58:16 +08:00
MaysWind a157c1961a fix the incorrect transaction text item 2026-01-05 23:18:17 +08:00
MaysWind 9a037ace5a remember last selected file type in import transaction dialog (#412) 2026-01-05 00:52:57 +08:00
MaysWind c64b4502cb support canceling the sorting operation on mobile version 2026-01-04 23:35:00 +08:00
MaysWind dc41bf8e10 replace the button labels in the navigation bar with a unified icons 2026-01-04 23:29:27 +08:00
MaysWind 0ce66d9070 support changing account category order 2026-01-04 22:50:13 +08:00
MaysWind 6e369f39a4 support setting account categories hidden which has no accounts 2026-01-04 14:10:03 +08:00
MaysWind fb25f589fb add clear all filters in import dialog (#416) 2026-01-04 11:02:09 +08:00
MaysWind 8651755d7a in the import dialog's data review table, keep the selection checkboxes and action button columns fixed in place 2026-01-04 10:31:52 +08:00
MaysWind 277da30339 update the supported currencies based on the exchange rate data source 2026-01-04 01:36:14 +08:00
MaysWind 2fb509beb2 support opening transaction view dialog in insights explorer page 2026-01-04 01:22:23 +08:00
MaysWind 6634d5b791 show transaction tags in insights explorer page 2026-01-04 01:21:33 +08:00
MaysWind 41739d97e7 show transaction date time in current timezone when hover over the transaction time 2026-01-04 00:39:47 +08:00
MaysWind 43bc04012d support setting timezone type in reconciliation statement dialog / page 2026-01-04 00:36:00 +08:00
MaysWind 43154832b6 support filtering geographic location and pictures in insights explorer 2026-01-03 22:42:58 +08:00
MaysWind 91a00cb5b3 support custom chart sorting order 2026-01-03 21:33:06 +08:00
MaysWind 526d7e50ec move the "Timezone Used for Date Range" option from insights explorer settings into each exploration 2026-01-03 20:46:42 +08:00
MaysWind cc0996e0d2 update name to insights explorer 2026-01-03 16:42:02 +08:00
Andrej Kralj 8be5e8aa1d Update Slovenian language
Sorry. One more change. Makes more sense in UI.
2026-01-02 09:57:42 +08:00
MaysWind 022dd3303b adjust the display order of the third party dependency home page url and license url 2026-01-02 00:55:44 +08:00
MaysWind 2865635013 update the supported currencies based on the exchange rate data source 2026-01-02 00:25:53 +08:00
MaysWind c276f261f9 show documentation in the iframe by default 2026-01-02 00:10:08 +08:00
MaysWind ee7e98bb00 show license type of third party dependency on about page 2026-01-01 23:55:35 +08:00
MaysWind 554ce37475 code refactor 2026-01-01 23:55:34 +08:00
MaysWind 1938d972ff bump year 2026-01-01 23:55:34 +08:00
Andrej Kralj 630859bc25 Update Slovenian translation
Fix for some typos.
2026-01-01 23:55:15 +08:00
MaysWind 8ea8a9fe2a add time-based categories "Transaction Day of Week", "Transaction Day of Month", "Transaction Month of Year" and "Transaction Quarter of Year" in insights & explore 2025-12-31 00:38:36 +08:00
MaysWind f5e4d82efc in insights & explore, time-based category supports calculated based on the transaction's time zone 2025-12-31 00:00:04 +08:00
MaysWind 958515b9e0 add new translation contributor 2025-12-30 22:52:07 +08:00
MaysWind 5131e3d6e3 update translation 2025-12-30 22:50:56 +08:00
Andrej Kralj b5a18c86dc Added Slovenian translation
Added Slovenian translation
2025-12-30 20:13:03 +08:00
MaysWind 2f3e26dbe5 revise ambiguous content 2025-12-30 00:55:22 +08:00
MaysWind 3313ccf051 add contributors to the about page 2025-12-30 00:28:20 +08:00
MaysWind 2ada077b38 update translation contributor 2025-12-30 00:10:21 +08:00
MaysWind 31c36f0edf fix the median amount was calculated incorrectly in account reconciliation statements 2025-12-29 00:11:33 +08:00
MaysWind e74d290016 add chart tab to insights & explore page 2025-12-28 23:58:38 +08:00
MaysWind 28337ae228 make the query name input field automatically adjust its width to match the text length 2025-12-27 23:54:20 +08:00
MaysWind e252378898 update query area style 2025-12-26 00:46:38 +08:00
MaysWind 1cc0cd7ae6 if X-Timezone-Name header is provided, always calculate the UTC offset based on the specified time 2025-12-26 00:19:16 +08:00
MaysWind 088e9a339d upgrade golang to 1.25.5, node.js to 24.12.0 and alpine base image to 3.23.2 2025-12-26 00:07:50 +08:00
MaysWind b009c7b6e5 prefer the value of X-Timezone-Name 2025-12-25 09:48:24 +08:00
MaysWind 6bb69b0c27 fix daylight saving time is not calculated correctly when checking whether a transaction can be edited 2025-12-25 00:59:08 +08:00
MaysWind 842683da25 update button style 2025-12-25 00:35:04 +08:00
MaysWind d39816bb9f support using parseDateTime function and IANA time zone names when importing DSV files using custom script 2025-12-25 00:24:26 +08:00
MaysWind e856aefd7b support date time with YYYY.MM.DD HH:mm:ss / MM.DD.YYYY HH:mm:ss / DD.MM.YYYY HH:mm:ss format when importing delimiter-separated values file / data 2025-12-24 23:13:14 +08:00
MaysWind f54c4998ef support IANA time zone names when importing DSV files using column mapping 2025-12-24 23:10:09 +08:00
MaysWind 59a138d417 change the file that reference third-party library 2025-12-24 09:26:54 +08:00
MaysWind 0dc2825e5d support renaming queries, duplicating queries, and displaying query expressions separately for each query 2025-12-24 01:32:15 +08:00
MaysWind 76af5d946a use the daylight saving time zone as default time zone rather than the current standard time zone during the DST 2025-12-24 00:33:47 +08:00
MaysWind c35cbbda15 automatically adjust table column widths based on their content 2025-12-21 14:10:38 +08:00
MaysWind ece58b60ec fix the month names were displayed incorrectly in the monthly income and expense trends chart when daylight saving time was involved (#392) 2025-12-21 02:35:25 +08:00
MaysWind d95e34a597 fix the dates in Statistics & Analysis page does not be processed for daylight saving time 2025-12-21 02:35:11 +08:00
MaysWind a09d7b57f9 automatically scroll to the selected item when opening the language selection drop down list menu 2025-12-21 02:34:57 +08:00
MaysWind a535fbcef1 use the same code for page scrolling on both the desktop and mobile versions 2025-12-21 02:34:35 +08:00
MaysWind 931d5e8395 show ellipsis when the time zone text is too long 2025-12-20 22:15:45 +08:00
MaysWind b37450db15 update style of "OK" button in the dialog 2025-12-20 22:12:18 +08:00
MaysWind 2dd7fd30de update style for long content 2025-12-20 00:53:37 +08:00
MaysWind fb55cd1b33 do not switch expression when there are no conditions 2025-12-20 00:40:58 +08:00
MaysWind e9b4392163 add insights & explore page 2025-12-18 00:55:01 +08:00
MaysWind 861e4c036b remove redundant code 2025-12-14 17:47:35 +08:00
MaysWind e825323bb0 add search box in filter account page / dialog 2025-12-14 17:41:50 +08:00
MaysWind aebd65449b fix the incorrect text color of the time zone in transaction view dialog on the desktop version 2025-12-14 01:41:21 +08:00
MaysWind 0a8f62741a remove redundant code 2025-12-14 01:06:40 +08:00
MaysWind b1cefa5a34 add search box in transaction category page / dialog 2025-12-14 01:05:42 +08:00
MaysWind a12038e40c fix the filter dropdown menu not display the selected items after selecting multiple hidden transaction categories or accounts 2025-12-14 01:03:51 +08:00
MaysWind b2fab42170 reduce dialog margins and make the action buttons always at the bottom of the dialog 2025-12-13 21:04:43 +08:00
MaysWind e9c3001c28 add search box in tag filter page / dialog (#382) 2025-12-13 01:16:51 +08:00
MaysWind 44039438e0 upgrade third party dependencies 2025-12-12 23:24:05 +08:00
MaysWind 372ea29edd hide search bar by clicking search icon 2025-12-12 14:30:25 +08:00
MaysWind 1eb958a21b show more user-friendly messages when some features are disabled 2025-12-12 13:42:46 +08:00
MaysWind 89dd306bb4 use i18n resource item to replace ambiguous configuration item 2025-12-12 12:30:56 +08:00
MaysWind c170cb42e6 add new translation contributor 2025-12-12 12:10:06 +08:00
MaysWind 78f3beaf2f update translation and default locale settings 2025-12-12 12:05:39 +08:00
aydnykn 482d025c90 Add files via upload 2025-12-12 10:57:48 +08:00
aydnykn 11c943efef Add files via upload 2025-12-12 10:57:48 +08:00
aydnykn 6debea6dbb Register Turkish language to index.ts 2025-12-12 10:57:48 +08:00
aydnykn a7a8b9a2fb Add Turkish localization file for the app 2025-12-12 10:57:48 +08:00
Albert Brugués 47cc046e60 improved spanish translations 2025-12-09 23:15:59 +08:00
MaysWind 5a9f4ec3b4 update style 2025-12-06 20:30:46 +08:00
MaysWind 70aa19c623 upgrade framework7 to 9.0.2 2025-12-06 20:30:39 +08:00
MaysWind d2771f6fa9 add new translation contributor 2025-12-06 01:19:30 +08:00
MaysWind 5e4637c6ad update locale default settings 2025-12-06 01:15:39 +08:00
Darshanbm05 a9a7d28082 added kannada language tranlation 2025-12-06 00:50:59 +08:00
Darshanbm05 ffce01c612 Add full Kannada locale src/locales/kn.json (copied from ko.json) to provide 2398-line placeholder translations 2025-12-06 00:50:59 +08:00
Darshanbm05 7a3ec9468f Add Kannada (ಕನ್ನಡ) language translation support
- Add frontend Kannada translations (src/locales/kn.json)
- Add backend Kannada locale text items (pkg/locales/kn.go)
- Update frontend language configuration (src/locales/index.ts)
- Update backend language registry (pkg/locales/all_locales.go)

Language code: kn (Kannada, ISO 639-1)
Display name: ಕನ್ನಡ
Text direction: ltr (left-to-right)
2025-12-06 00:50:59 +08:00
MaysWind 5850e0e298 update sheet height 2025-12-06 00:44:27 +08:00
MaysWind fdf6548cc9 hide the search bar by default on the mobile transaction list page 2025-12-06 00:44:20 +08:00
MaysWind ee8aa2bb8e use popover-close property to close popover 2025-12-05 23:00:08 +08:00
MaysWind eccea273e6 make the styling consistent across all pages of the mobile version 2025-12-05 00:21:07 +08:00
MaysWind e143c8f098 automatically detect file encoding when importing delimiter-separated values (DSV) file 2025-12-03 23:56:13 +08:00
MaysWind 81226c3bb2 add new translation contributor 2025-12-02 23:52:49 +08:00
Albert Brugués c3db8cee2d Spanish translations changed to be familiar 2025-12-02 23:49:58 +08:00
Albert Brugués 9061fc3188 Spanish translations changed to title case 2025-12-02 23:49:58 +08:00
Albert Brugués b9539c4aba Fixed mistakes in Spanish translations 2025-12-02 23:49:58 +08:00
Albert Brugués deabe178df Added missing Spanish translations 2025-12-02 23:49:58 +08:00
MaysWind a5320cf929 development server supports proxying mcp, avatar and transaction image requests 2025-12-02 23:46:33 +08:00
MaysWind 79842d9171 fix cannot access Alibaba Cloud OSS using minio object storage type (#230) 2025-12-02 23:27:24 +08:00
MaysWind 3daff44155 remove outdated code 2025-12-02 00:30:52 +08:00
MaysWind cd2cce4268 update style 2025-12-02 00:30:36 +08:00
MaysWind ad132d5637 fix input field placeholders overlap after upgrading vuetify to version 3.11.0 2025-12-01 01:32:17 +08:00
MaysWind c8b3daa915 make the styling consistent across all pages of the mobile version 2025-12-01 00:55:22 +08:00
MaysWind 96561ec2be upgrade framework7 to 9.0 2025-11-30 03:22:53 +08:00
MaysWind 608411feab upgrade third party dependencies 2025-11-29 22:02:19 +08:00
MaysWind 516e3a5613 upgrade third party dependencies 2025-11-29 19:00:23 +08:00
MaysWind 2431152cec support batch converting amounts to positive / negative values in import dialog 2025-11-27 01:43:01 +08:00
MaysWind 17f604b6aa support filtering transactions by amount in import transaction dialog 2025-11-27 01:25:34 +08:00
MaysWind 3fe51dce63 fix the divider line was positioned incorrectly 2025-11-27 00:01:07 +08:00
MaysWind 2c454f001e support importing amounts that use non-breaking space (NBSP), narrow no-break space (NNBSP) or figure space as digit grouping symbol when importing delimiter-separated values file / data (#361) 2025-11-26 23:57:54 +08:00
MaysWind 4781cb34eb support dates with YYYY.MM.DD / MM.DD.YYYY / DD.MM.YYYY format when importing delimiter-separated values file / data (#361) 2025-11-26 23:31:08 +08:00
MaysWind 9faea14e36 support import delimiter-separated values file / data with UTF-16 with BOM encoding (#361) 2025-11-26 23:30:35 +08:00
MaysWind bd704a8c15 update function name 2025-11-25 01:25:56 +08:00
MaysWind bb9a19bcb2 import member, project and merchant fields as tags when importing feidee mymoney export file 2025-11-25 01:21:44 +08:00
MaysWind 9ff1334584 import payee field as tags when importing a QIF file (#356) 2025-11-25 00:56:28 +08:00
MaysWind de27c8e6c5 fix no results were shown when previewing all results while importing transactions with user custom script 2025-11-24 22:38:00 +08:00
MaysWind ba278e47ff the preview count dropdown menu always shows the "10" option 2025-11-24 22:37:24 +08:00
MaysWind 7d70859107 modify style 2025-11-24 22:04:20 +08:00
MaysWind 6430a52027 tag filter supports selecting both included and excluded tags simultaneously 2025-11-24 02:21:03 +08:00
MaysWind 45be96cf68 adjust column widths to fit their content 2025-11-23 19:06:37 +08:00
MaysWind 837a62a534 paste amount on number pad sheet for ios 2025-11-23 02:32:12 +08:00
MaysWind 707283fd66 hide paste amount menu item for ios 2025-11-23 01:57:29 +08:00
MaysWind 10b9c09192 insert the pasted content after the cursor when pasting numbers or amounts 2025-11-23 01:44:40 +08:00
MaysWind ed8c5c96ac set allowed number range 2025-11-23 01:44:34 +08:00
MaysWind 3ba91c590e highlight the current row 2025-11-23 00:49:43 +08:00
MaysWind 44dc45de51 transaction reconciliation statement supports sorting by account name and category name on desktop version 2025-11-23 00:33:54 +08:00
MaysWind 83bd68e7f4 use the number system configured in the user's settings for all numeric values in the token generation dialog 2025-11-22 23:49:23 +08:00
MaysWind dafbc115c4 support pasting amount from clipboard on mobile version 2025-11-22 23:29:09 +08:00
MaysWind 5de1e32cd8 update text 2025-11-22 20:04:27 +08:00
MaysWind 8ae5c1ea99 limit the maximum height of the date range menu 2025-11-19 00:53:41 +08:00
MaysWind 29651f674a code refactor 2025-11-19 00:00:20 +08:00
MaysWind b1dff5ef51 add error log 2025-11-17 23:26:58 +08:00
MaysWind bb0971ea17 modify log content 2025-11-17 23:18:11 +08:00
MaysWind 8a020b666c fix the incorrect email verify page, reset password page, and OAuth 2.0 callback page url when accessing ezBookkeeping through a subpath (#348) 2025-11-17 00:38:04 +08:00
MaysWind 8b34750426 fix the incorrect display type name of transaction categories 2025-11-14 00:02:57 +08:00
MaysWind 32cb2b2354 bump version to 1.3.0 2025-11-09 23:29:33 +08:00
MaysWind 4c8bb5a0b7 add asset trends in statistics & analysis (#314) 2025-11-09 22:51:46 +08:00
MaysWind d3abb279e3 code refactor 2025-11-09 22:51:42 +08:00
MaysWind 952731a2d4 show confirm dialog before opening external link 2025-11-06 00:51:09 +08:00
MaysWind df23cb8cdd do not refresh token when enter verify email / reset password / oauth 2.0 callback page 2025-11-06 00:12:50 +08:00
MaysWind 87a21a1a4f language selector on the login and unlock page is displayed at the bottom 2025-11-04 00:33:41 +08:00
MaysWind 7c3c1bbd6a show example after api token generated 2025-11-03 23:08:18 +08:00
MaysWind 03c342f6f6 check whether api token is enabled when using an api token 2025-11-03 22:52:15 +08:00
MaysWind b0e01d36ab generate API token in frontend page 2025-11-03 01:27:45 +08:00
MaysWind bb84e8af13 support skipping issuer url verification in OIDC authentication 2025-11-02 01:37:52 +08:00
MaysWind f42ee9cf67 hide total amount divider line in trend charts when no items are displayed 2025-11-02 01:12:14 +08:00
MaysWind 8a0232aedf support exporting transactions dated later than the current time 2025-11-02 01:07:19 +08:00
MaysWind f3ccd3b66d show total income and total expense in categorical overview chart 2025-11-02 01:05:40 +08:00
MaysWind b690316aa7 support linking OAuth 2.0 user to logged-in users 2025-10-31 01:22:47 +08:00
MaysWind 8a0777be4c display amount when the inflow/outflow chart shows only one item 2025-10-30 00:35:25 +08:00
MaysWind 013f44f64a update security warning text 2025-10-30 00:29:09 +08:00
MaysWind 9f8dbf77df add bubble chart for trends analysis 2025-10-30 00:18:52 +08:00
MaysWind 274fb8b4e2 support selecting all visible in account / category / tag filter dialog / page 2025-10-29 00:52:03 +08:00
MaysWind 48a06c6570 hide export menu item for categorical analysis overview chart 2025-10-29 00:19:32 +08:00
MaysWind 5485242baf show percent in categorical analysis overview chart 2025-10-28 23:53:52 +08:00
MaysWind ec7c4c7461 fix the incorrect amount in the total outflows and inflows trend charts 2025-10-28 12:48:06 +08:00
MaysWind 2259719935 filter transfer in accounts in statistics & analysis 2025-10-28 01:57:17 +08:00
MaysWind f8fc955408 update sankey chart display rules 2025-10-28 01:52:03 +08:00
MaysWind 4684de9705 support mouse zoom for the sankey chart 2025-10-28 00:48:42 +08:00
MaysWind 52bab6f726 change the outflows/inflows by account trend charts to non-stacked charts and hide the total amount for these charts 2025-10-28 00:44:43 +08:00
MaysWind 765e64d96f hide percent for outflows/inflows by account categorical chart 2025-10-27 23:49:31 +08:00
MaysWind f93610b5e0 total outflows / inflows do not include transfer transactions between unfiltered accounts 2025-10-27 22:33:44 +08:00
MaysWind 5d1480cabc add overview sankey chart for categorical analysis on desktop version 2025-10-27 03:37:29 +08:00
MaysWind 5faf3bfe66 fix cannot shift the date range in the trend analysis after switching from categorical analysis with the date range set to All 2025-10-27 01:00:59 +08:00
MaysWind 5cb7eca340 add outflows / inflows / net cash flow in statistics & analysis 2025-10-27 00:52:41 +08:00
MaysWind 9a2f682379 format code 2025-10-26 22:11:47 +08:00
MaysWind fd4036f0c8 verify passcode on the OAuth 2.0 callback page if user enable 2FA 2025-10-26 15:18:41 +08:00
MaysWind c854dbaab4 modify style 2025-10-26 15:08:48 +08:00
MaysWind 0c75ed47ac upgrade golang to 1.25.3 2025-10-26 14:16:52 +08:00
MaysWind fa467e72f9 update http status code 2025-10-26 13:51:09 +08:00
MaysWind 93e05d5634 upgrade third party dependencies 2025-10-26 13:17:24 +08:00
MaysWind 745efe1222 add radar chart for categorical analysis on desktop version 2025-10-26 02:01:51 +08:00
MaysWind e1dcf56ca9 upgrade third party dependencies 2025-10-26 02:01:11 +08:00
MaysWind 3aa33a48e9 downgrade gin gzip middleware to 1.2.3 (because https://github.com/gin-contrib/gzip/issues/122) 2025-10-26 02:00:34 +08:00
MaysWind 29547bccb1 upgrade third party dependencies 2025-10-25 18:46:15 +08:00
MaysWind 4823760fd1 feature restriction supports OAuth 2.0 login and unlinking third-party login 2025-10-25 15:50:25 +08:00
MaysWind 8584e84af9 add missing comment and code 2025-10-25 15:40:59 +08:00
MaysWind af586a0432 hide third-party logins when OAuth 2.0 login is not enabled 2025-10-25 15:19:42 +08:00
MaysWind ce752c992c support unlinking external authentication 2025-10-25 02:51:25 +08:00
MaysWind 7b49a9f142 show preloader when redirecting to OAuth 2.0 authorization page 2025-10-25 00:42:20 +08:00
MaysWind 2fc5e91cc4 modify style when logging in 2025-10-25 00:42:05 +08:00
MaysWind f6d03bf5df show error page when the OAuth 2.0 redirect fails 2025-10-24 23:38:29 +08:00
MaysWind a17a2cc377 add option to control whether PKCE is used in OAuth 2.0 authentication process 2025-10-24 23:38:21 +08:00
MaysWind beea6fe733 retrieve user email address via the GitHub user email API when logging in with GitHub 2025-10-24 01:45:16 +08:00
MaysWind 85b05f9e7e support OIDC authentication (#242) 2025-10-24 01:44:55 +08:00
MaysWind d3ab2b94b7 verify the username, email and nickname are valid when registering via OAuth 2.0 2025-10-23 22:46:31 +08:00
MaysWind b21fff5b15 show waiting state while redirecting to the OAuth 2.0 authorization page 2025-10-23 00:42:29 +08:00
MaysWind 234e7a55ff support Gitea OAuth 2.0 authentication 2025-10-23 00:16:28 +08:00
MaysWind d4cf8fe077 update test case 2025-10-22 23:23:43 +08:00
MaysWind 2b2a266533 set nickname to username if nickname is empty 2025-10-22 22:39:06 +08:00
MaysWind 4b35103e34 support GitHub OAuth 2.0 authentication 2025-10-22 22:18:04 +08:00
MaysWind 81a5585029 fix typo 2025-10-22 22:07:41 +08:00
MaysWind d2b89e629a add comment 2025-10-22 22:07:01 +08:00
MaysWind cab86eec68 fix typo 2025-10-22 21:34:52 +08:00
MaysWind 295f5cc14a add failure retry detection to the sending forget password mail 2025-10-22 00:30:00 +08:00
MaysWind 6395e3b5c1 support for users without a password to change their password 2025-10-22 00:15:39 +08:00
MaysWind a42c5fa988 fix new users could not be automatically created when signing in via oauth 2.0 2025-10-21 23:59:18 +08:00
MaysWind 46e275d843 store oauth 2.0 user info in token context instead of being passed through frontend parameters 2025-10-21 23:51:13 +08:00
MaysWind 13ada3575a code refactor 2025-10-21 22:43:52 +08:00
MaysWind 3b0e0f1a3f refactored common oauth 2.0 provider and add unit tests for nextcloud oauth 2.0 data source 2025-10-21 22:41:41 +08:00
MaysWind 512acc5a49 fix error message is not localized text on the OAuth 2.0 callback page 2025-10-21 22:12:26 +08:00
MaysWind 1f101fea3e redirect to different pages based on user's device 2025-10-21 21:34:53 +08:00
MaysWind 83bd8f23f4 modify variable name 2025-10-21 21:33:49 +08:00
MaysWind af56c3057c fix cannot start when oauth2 provider not set 2025-10-21 21:32:59 +08:00
MaysWind 53a8ad71c6 support Nextcloud OAuth 2.0 authentication 2025-10-21 01:52:28 +08:00
MaysWind 600ae2bd58 only show asterisks for passwords with values in startup config output 2025-10-18 01:18:12 +08:00
MaysWind 60b6ed51cd hide unsupported filters when selecting Account Total Assets or Account Total Liabilities on the Statistics & Analysis page 2025-10-18 00:38:48 +08:00
MaysWind 8a947ef224 pasting date time from clipboard supports current language date time format 2025-10-18 00:24:30 +08:00
MaysWind d936b64cf9 build script supports setting the build time and date through environment variables 2025-10-17 21:57:33 +08:00
MaysWind ab828ebdab update README.md 2025-10-17 21:54:43 +08:00
MaysWind b444e8ee31 display a security warning when using AI image recognition 2025-10-17 00:52:47 +08:00
MaysWind 45f1177a73 display a reminder to check important information when using AI image recognition 2025-10-17 00:34:56 +08:00
MaysWind 64e7dc5e12 modify the prompt text 2025-10-16 23:51:28 +08:00
MaysWind e62bebb7fa display a security warning when generating an MCP token 2025-10-16 23:47:36 +08:00
MaysWind 3990a072ca add an MCP tool query_all_accounts_balance for retrieving all account balances (#309) 2025-10-15 23:48:08 +08:00
MaysWind a5bd12945d upgrade golang to 1.25.3, node.js to 24.10.0, alpine base image to 3.22.2 2025-10-15 23:16:45 +08:00
MaysWind 7938e7c7c8 fix the Persian Calendar was not displayed when the calendar display type was set to Gregorian with Persian Calendar, but the date display type was not set to Persian 2025-10-15 23:10:36 +08:00
MaysWind 130a157abc fix cannot move or delete sub-account all transactions 2025-10-14 08:56:45 +08:00
MaysWind cce19ae957 when a sub-account is deleted, set the currently selected account to its parent account 2025-10-14 08:50:22 +08:00
MaysWind e90340fec4 use arm runner to build arm64/v7/v6 docker image 2025-10-13 21:26:01 +08:00
MaysWind 22061e535a optimize github actions build speed 2025-10-13 00:21:32 +08:00
MaysWind 23a85d6162 update action version 2025-10-13 00:04:05 +08:00
MaysWind 2cb47bfd75 move all transactions from one account to another account (#288) 2025-10-11 01:12:33 +08:00
MaysWind 3ce7f6e99a disable account picker, date time picker and timezone picker when editing balance modification transaction 2025-10-11 00:42:44 +08:00
MaysWind 77b083c41b remove redundant content in function names 2025-10-10 23:09:53 +08:00
MaysWind b8fcdacb84 fix incorrect error code 2025-10-10 19:32:28 +08:00
MaysWind d893193e73 load configuration option value from file 2025-10-10 17:12:06 +08:00
MaysWind 73f8446d07 support modifying amount and description when importing transactions 2025-10-09 22:41:43 +08:00
MaysWind 5692bec216 support pasting date time into the date-time picker on the desktop version 2025-10-09 21:38:35 +08:00
MaysWind e88491268b hide the "Set Location" button in the map sheet on the transaction view page in mobile version 2025-10-09 20:04:50 +08:00
MaysWind 94cd5dc21a support the latest format of alipay statement file 2025-10-09 19:41:36 +08:00
MaysWind 697f69d5d7 use a fixed browser version as the build target 2025-10-06 21:49:12 +08:00
MaysWind 76fff27b3f fix cannot modify transaction time after duplicating transaction with time on the mobile version 2025-10-06 16:29:44 +08:00
MaysWind 4f664dbfc3 code refactor 2025-10-06 16:29:31 +08:00
MaysWind d9b726cdf9 add FAQ link 2025-10-04 14:40:14 +08:00
MaysWind cc792b9c0f import credit card repayment transactions from wechat pay statement file (#279) 2025-10-04 12:54:59 +08:00
MaysWind b916217b28 fix the mobile transaction list page displayed incorrectly when loading more transactions 2025-10-02 21:30:19 +08:00
MaysWind 6c4b7059ed add new translation contributor 2025-10-02 19:52:48 +08:00
MaysWind 9d0e294ed2 update translation 2025-10-02 19:51:02 +08:00
Minhyung Park dc6d8398b1 add Korean 2025-10-02 07:22:03 +08:00
MaysWind a50d2e7e72 add a link to Usage Questions discussion when creating a new issue 2025-10-01 22:05:53 +08:00
MaysWind ea5cfe60f2 don't automatically add label when creating issues 2025-09-30 09:37:40 +08:00
MaysWind 5b37ea4d78 AI image recognition supports pasting image from clipboard in desktop version 2025-09-29 19:36:31 +08:00
MaysWind 46dd2888a6 show hint while loading the image 2025-09-29 19:19:35 +08:00
MaysWind 564c9e1d95 update description 2025-09-29 16:41:47 +08:00
MaysWind 4d0d3959a9 support canceling AI image recognition 2025-09-27 23:53:40 +08:00
MaysWind e9e6644e7f update name format of docker image to avoid conflicts with version detection 2025-09-27 21:40:15 +08:00
MaysWind 6a19131ea1 bump version to 1.2.0 2025-09-27 21:33:07 +08:00
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
640 changed files with 122150 additions and 19702 deletions
+6 -3
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Docker meta
id: meta
@@ -25,14 +25,15 @@ jobs:
- 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: Set up the environment
id: setup
run: |
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
cat >> docker/custom-backend-pre-setup.sh <<EOF
#!/bin/sh
@@ -55,6 +56,8 @@ jobs:
build-args: |
RELEASE_BUILD=1
BUILD_PIPELINE=1
BUILD_UNIXTIME=${{ steps.setup.outputs.build_unix_time }}
BUILD_DATE=${{ steps.setup.outputs.build_date }}
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
+6 -3
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Docker meta
id: meta
@@ -25,14 +25,15 @@ jobs:
- 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: Set up the environment
id: setup
run: |
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
cat >> docker/custom-backend-pre-setup.sh <<EOF
#!/bin/sh
@@ -54,6 +55,8 @@ jobs:
push: true
build-args: |
BUILD_PIPELINE=1
BUILD_UNIXTIME=${{ steps.setup.outputs.build_unix_time }}
BUILD_DATE=${{ steps.setup.outputs.build_date }}
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
+69
View File
@@ -0,0 +1,69 @@
name: Bug Report
description: Report a bug in ezBookkeeping
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.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Frequently Asked Questions
url: https://ezbookkeeping.mayswind.net/faq
about: Please check whether your question has already been mentioned here.
- name: Usage Questions
url: https://github.com/mayswind/ezbookkeeping/discussions/categories/q-a
about: Questions about using ezBookkeeping can be discussed in GitHub Discussions.
@@ -0,0 +1,25 @@
name: Feature Request
description: Request a feature or enhancement for ezBookkeeping
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.
@@ -0,0 +1,124 @@
name: Build docker image and package for linux
inputs:
release-build:
required: false
type: string
build-unix-time:
required: false
type: string
build-date:
required: false
type: string
check-3rd-api:
required: false
type: string
skip-tests:
required: false
type: string
platform:
required: true
type: string
platform-name:
required: true
type: string
docker-push:
required: true
type: boolean
docker-image-name:
required: true
type: string
docker-username:
required: false
type: string
docker-password:
required: false
type: string
docker-bake-meta-file-path:
required: true
type: string
docker-bake-meta-artifact-name:
required: true
type: string
docker-bake-digests-file-path:
required: true
type: string
docker-bake-digests-artifact-name-prefix:
required: true
type: string
package-file-name-prefix:
required: true
type: string
package-artifact-name-prefix:
required: true
type: string
runs:
using: "composite"
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Download docker bake meta artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.docker-bake-meta-artifact-name }}
path: ${{ runner.temp }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: ${{ inputs.docker-username != '' && inputs.docker-password != '' }}
uses: docker/login-action@v3
with:
username: ${{ inputs.docker-username }}
password: ${{ inputs.docker-password }}
- name: Build docker for ${{ inputs.platform-name }}
id: bake
uses: docker/bake-action@v6
with:
files: |
./docker-bake.hcl
cwd://${{ inputs.docker-bake-meta-file-path }}
source: .
targets: image
set: |
*.tags=${{ inputs.docker-image-name }}
*.platform=${{ inputs.platform }}
*.args.RELEASE_BUILD=${{ inputs.release-build }}
*.args.BUILD_PIPELINE=1
*.args.BUILD_UNIXTIME=${{ inputs.build-unix-time }}
*.args.BUILD_DATE=${{ inputs.build-date }}
*.args.CHECK_3RD_API=${{ inputs.check-3rd-api }}
*.args.SKIP_TESTS=${{ inputs.skip-tests }}
*.output=type=image,push-by-digest=true,name-canonical=true,push=${{ inputs.docker-push }}
*.output+=type=local,dest=${{ runner.temp }}/package
- name: Export digests file for ${{ inputs.platform-name }}
shell: bash
run: |
digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"
mkdir -p ${{ inputs.docker-bake-digests-file-path }}
touch "${{ inputs.docker-bake-digests-file-path }}/${digest#sha256:}"
- name: Build package file for ${{ inputs.platform-name }}
shell: bash
run: |
cd ${{ runner.temp }}/package/ezbookkeeping
tar -czf ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz *
- name: Upload ${{ inputs.platform-name }} digests artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-${{ inputs.platform-name }}
path: ${{ inputs.docker-bake-digests-file-path }}/*
if-no-files-found: error
- name: Upload artifact for ${{ inputs.platform-name }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.package-artifact-name-prefix }}-${{ inputs.platform-name }}
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz
if-no-files-found: error
@@ -0,0 +1,76 @@
name: Build backend file for windows
inputs:
go-version:
required: false
default: "1.25.5"
mingw-version:
required: false
default: "15.2.0"
mingw-revison:
required: false
default: "v13-rev0"
release-build:
required: false
type: string
build-unix-time:
required: false
type: string
build-date:
required: false
type: string
check-3rd-api:
required: false
type: string
skip-tests:
required: false
type: string
backend-artifact-name-prefix:
required: true
type: string
runs:
using: "composite"
steps:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ inputs.go-version }}
- name: Install MinGW
shell: pwsh
run: |
$mingwVersion = "${{ inputs.mingw-version }}"
$mingwRevision = "${{ inputs.mingw-revison }}"
$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
shell: pwsh
env:
RELEASE_BUILD: "${{ inputs.release-build }}"
BUILD_PIPELINE: "1"
BUILD_UNIXTIME: "${{ inputs.build-unix-time }}"
BUILD_DATE: "${{ inputs.build-date }}"
CHECK_3RD_API: "${{ inputs.check-3rd-api }}"
SKIP_TESTS: "${{ inputs.skip-tests }}"
run: |
.\build.ps1 backend
- name: Upload windows backend artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
path: ezbookkeeping.exe
if-no-files-found: error
@@ -0,0 +1,57 @@
name: Build packages for windows
inputs:
package-file-name-prefix:
required: true
type: string
package-artifact-name-prefix:
required: true
type: string
backend-artifact-name-prefix:
required: true
type: string
runs:
using: "composite"
steps:
- name: Download windows-x64 backend file
uses: actions/download-artifact@v4
with:
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
path: ${{ runner.temp }}\backend
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v4
with:
name: ${{ inputs.package-artifact-name-prefix }}-linux-amd64
path: ${{ runner.temp }}\package
- name: Extract frontend files from linux-amd64 package
shell: pwsh
run: |
New-Item -ItemType Directory -Path package
tar -xzf (Get-ChildItem ${{ runner.temp }}\package\${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz) -C package
- name: Package windows-x64 package
shell: pwsh
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 ${{ runner.temp }}\backend\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 ..\${{ inputs.package-file-name-prefix }}-windows-x64.zip *
Pop-Location
Remove-Item -Recurse -Force ezbookkeeping
- name: Upload windows artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.package-artifact-name-prefix }}-windows-x64
path: ${{ inputs.package-file-name-prefix }}-windows-x64.zip
if-no-files-found: error
@@ -0,0 +1,58 @@
name: Push linux docker multi-arch image to registry
inputs:
docker-image-name:
required: true
type: string
docker-username:
required: true
type: string
docker-password:
required: true
type: string
docker-bake-meta-file-path:
required: true
type: string
docker-bake-meta-artifact-name:
required: true
type: string
docker-bake-digests-file-path:
required: true
type: string
docker-bake-digests-artifact-name-prefix:
required: true
type: string
docker-image-tags:
required: true
type: string
runs:
using: "composite"
steps:
- name: Download docker bake meta artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.docker-bake-meta-artifact-name }}
path: ${{ runner.temp }}
- name: Download digests artifact
uses: actions/download-artifact@v4
with:
pattern: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-*
merge-multiple: true
path: ${{ inputs.docker-bake-digests-file-path }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ inputs.docker-username }}
password: ${{ inputs.docker-password }}
- name: Create manifest and push
shell: bash
working-directory: ${{ inputs.docker-bake-digests-file-path }}
run: |
docker buildx imagetools create $(echo "${{ inputs.docker-image-tags }}" | xargs -I {} echo -n " -t {}") $(printf '${{ inputs.docker-image-name }}@sha256:%s ' *)
+149
View File
@@ -0,0 +1,149 @@
name: Build for Non-Main Branches
on:
push:
branches-ignore:
- main
jobs:
setup:
runs-on: ubuntu-latest
outputs:
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
build-date: ${{ steps.variable.outputs.build_date }}
docker-tags: ${{ steps.meta.outputs.tags }}
docker-labels: ${{ steps.meta.outputs.labels }}
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ vars.DOCKER_IMAGE_NAME }}
tags: |
type=raw,value=dev-${{ github.run_id }}
- name: Set up variables
id: variable
run: |
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-dev-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
- name: Rename docker bake meta file
run: |
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
- name: Upload docker bake meta artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
if-no-files-found: error
build-linux-docker-and-package-x86:
runs-on: ubuntu-24.04
needs:
- setup
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-linux-docker-and-package
with:
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
platform: linux/amd64
platform-name: linux-amd64
docker-push: false
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
build-linux-docker-and-package-arm:
runs-on: ubuntu-24.04-arm
needs:
- setup
strategy:
matrix:
include:
- platform: linux/arm64/v8
platform-name: linux-arm64
- platform: linux/arm/v7
platform-name: linux-armv7
- platform: linux/arm/v6
platform-name: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-linux-docker-and-package
with:
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
platform: ${{ matrix.platform }}
platform-name: ${{ matrix.platform-name }}
docker-push: false
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
build-windows-backend:
needs:
- setup
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-windows-backend
with:
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-windows-package:
needs:
- setup
- build-windows-backend
- build-linux-docker-and-package-x86
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-windows-package
with:
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
+204
View File
@@ -0,0 +1,204 @@
name: Build Release
on:
push:
tags:
- "v*.*.*"
jobs:
setup:
runs-on: ubuntu-latest
outputs:
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
build-date: ${{ steps.variable.outputs.build_date }}
docker-version: ${{ steps.meta.outputs.version }}
docker-tags: ${{ steps.meta.outputs.tags }}
docker-labels: ${{ steps.meta.outputs.labels }}
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ vars.DOCKER_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up variables
id: variable
run: |
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-release-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-release-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-release-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-release-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
- name: Rename docker bake meta file
run: |
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
- name: Upload docker bake meta artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
if-no-files-found: error
build-linux-docker-and-package-x86:
runs-on: ubuntu-24.04
needs:
- setup
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-linux-docker-and-package
with:
release-build: 1
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
platform: linux/amd64
platform-name: linux-amd64
docker-push: true
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
build-linux-docker-and-package-arm:
runs-on: ubuntu-24.04-arm
needs:
- setup
strategy:
matrix:
include:
- platform: linux/arm64/v8
platform-name: linux-arm64
- platform: linux/arm/v7
platform-name: linux-armv7
- platform: linux/arm/v6
platform-name: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-linux-docker-and-package
with:
release-build: 1
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
platform: ${{ matrix.platform }}
platform-name: ${{ matrix.platform-name }}
docker-push: true
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
push-linux-docker:
needs:
- setup
- build-linux-docker-and-package-x86
- build-linux-docker-and-package-arm
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/push-linux-docker
with:
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
docker-image-tags: ${{ needs.setup.outputs.docker-tags }}
build-windows-backend:
needs:
- setup
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-windows-backend
with:
release-build: 1
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-windows-package:
needs:
- setup
- build-windows-backend
- build-linux-docker-and-package-x86
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-windows-package
with:
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
publish-release:
runs-on: ubuntu-latest
needs:
- setup
- build-linux-docker-and-package-x86
- build-linux-docker-and-package-arm
- build-windows-package
- push-linux-docker
steps:
- name: Download all packaged files
uses: actions/download-artifact@v4
with:
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}-*
merge-multiple: true
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
+177
View File
@@ -0,0 +1,177 @@
name: Build Snapshot
on:
push:
branches:
- main
jobs:
setup:
runs-on: ubuntu-latest
outputs:
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
build-date: ${{ steps.variable.outputs.build_date }}
docker-version: ${{ steps.meta.outputs.version }}
docker-tags: ${{ steps.meta.outputs.tags }}
docker-labels: ${{ steps.meta.outputs.labels }}
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ vars.DOCKER_IMAGE_NAME }}
tags: |
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}-${{ github.run_id }}
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
type=raw,value=latest-snapshot
- name: Set up variables
id: variable
run: |
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-dev-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
- name: Rename docker bake meta file
run: |
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
- name: Upload docker bake meta artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
if-no-files-found: error
build-linux-docker-and-package-x86:
runs-on: ubuntu-24.04
needs:
- setup
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-linux-docker-and-package
with:
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
platform: linux/amd64
platform-name: linux-amd64
docker-push: true
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
build-linux-docker-and-package-arm:
runs-on: ubuntu-24.04-arm
needs:
- setup
strategy:
matrix:
include:
- platform: linux/arm64/v8
platform-name: linux-arm64
- platform: linux/arm/v7
platform-name: linux-armv7
- platform: linux/arm/v6
platform-name: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-linux-docker-and-package
with:
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
platform: ${{ matrix.platform }}
platform-name: ${{ matrix.platform-name }}
docker-push: true
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
push-linux-docker:
needs:
- setup
- build-linux-docker-and-package-x86
- build-linux-docker-and-package-arm
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/push-linux-docker
with:
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
docker-username: ${{ vars.DOCKER_USERNAME }}
docker-password: ${{ secrets.DOCKER_PASSWORD }}
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
docker-image-tags: ${{ needs.setup.outputs.docker-tags }}
build-windows-backend:
needs:
- setup
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-windows-backend
with:
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
build-date: ${{ needs.setup.outputs.build-date }}
check-3rd-api: ${{ vars.CHECK_3RD_API }}
skip-tests: ${{ vars.SKIP_TESTS }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
build-windows-package:
needs:
- setup
- build-windows-backend
- build-linux-docker-and-package-x86
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: ./.github/actions/build-windows-package
with:
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
-57
View File
@@ -1,57 +0,0 @@
name: Docker Release
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
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 }}
-55
View File
@@ -1,55 +0,0 @@
name: Docker Snapshot
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
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'}}
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 }}
@@ -1,32 +0,0 @@
name: Docker Snapshot
on:
push:
branches-ignore:
- main
jobs:
build:
runs-on: ubuntu-latest
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: Build
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: linux/amd64
push: false
build-args: |
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
+11 -3
View File
@@ -1,11 +1,15 @@
# Build backend binary file
FROM golang:1.24.4-alpine3.22 AS be-builder
FROM golang:1.25.5-alpine3.23 AS be-builder
ARG RELEASE_BUILD
ARG BUILD_PIPELINE
ARG BUILD_UNIXTIME
ARG BUILD_DATE
ARG CHECK_3RD_API
ARG SKIP_TESTS
ENV RELEASE_BUILD=$RELEASE_BUILD
ENV BUILD_PIPELINE=$BUILD_PIPELINE
ENV BUILD_UNIXTIME=$BUILD_UNIXTIME
ENV BUILD_DATE=$BUILD_DATE
ENV CHECK_3RD_API=$CHECK_3RD_API
ENV SKIP_TESTS=$SKIP_TESTS
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
@@ -15,11 +19,15 @@ RUN apk add git gcc g++ libc-dev
RUN ./build.sh backend
# Build frontend files
FROM --platform=$BUILDPLATFORM node:22.16.0-alpine3.22 AS fe-builder
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine3.23 AS fe-builder
ARG RELEASE_BUILD
ARG BUILD_PIPELINE
ARG BUILD_UNIXTIME
ARG BUILD_DATE
ENV RELEASE_BUILD=$RELEASE_BUILD
ENV BUILD_PIPELINE=$BUILD_PIPELINE
ENV BUILD_UNIXTIME=$BUILD_UNIXTIME
ENV BUILD_DATE=$BUILD_DATE
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
COPY . .
RUN docker/frontend-build-pre-setup.sh
@@ -27,7 +35,7 @@ RUN apk add git
RUN ./build.sh frontend
# Package docker image
FROM alpine:3.22.0
FROM alpine:3.23.2
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-2025 MaysWind (i@mayswind.net)
Copyright (c) 2020-2026 MaysWind (i@mayswind.net)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+30 -12
View File
@@ -1,14 +1,19 @@
# 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, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features. Built with simplicity and portability in mind, it's easy to deploy, easy to use, and requires minimal system resources — perfect for microservers, NAS devices, and even Raspberry Pi.
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.
The app is fully cross-platform and device-friendly — you can use it seamlessly on **mobile, tablet, 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.
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)
@@ -27,6 +32,7 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
- 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
@@ -79,7 +85,7 @@ Download the latest release: [https://github.com/mayswind/ezbookkeeping/releases
By default, ezBookkeeping listens on port 8080. You can then visit `http://{YOUR_HOST_ADDRESS}:8080/` .
### Build from Source
Make sure you have [Golang](https://golang.org/), [GCC](http://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
Make sure you have [Golang](https://golang.org/), [GCC](https://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**
@@ -91,6 +97,10 @@ 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 a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps:
@@ -100,7 +110,7 @@ You can also build a Docker image. Make sure you have [Docker](https://www.docke
$ ./build.sh docker
## Contributing
We welcome contributions of all kinds!
We welcome contributions of all kinds.
Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues)
@@ -108,10 +118,10 @@ 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 whove already helped.
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).
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:
@@ -119,21 +129,29 @@ Currently available translations:
| --- | --- | --- |
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
| en | English | / |
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon) |
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
| it | Italiano | [@waron97](https://github.com/waron97) |
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) |
| ko | 한국어 | [@overworks](https://github.com/overworks) |
| nl | Nederlands | [@automagics](https://github.com/automagics) |
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
| ru | Русский | [@artegoser](https://github.com/artegoser) |
| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) |
| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) |
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) |
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
| zh-Hans | 中文 (简体) | / |
| zh-Hant | 中文 (繁體) | / |
Don't see your language? Help us add it!
Don't see your language? Help us add it.
## Documentation
1. [English](http://ezbookkeeping.mayswind.net)
1. [中文 (简体)](http://ezbookkeeping.mayswind.net/zh_Hans)
1. [English](https://ezbookkeeping.mayswind.net)
1. [中文 (简体)](https://ezbookkeeping.mayswind.net/zh_Hans)
## License
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
+12 -6
View File
@@ -8,8 +8,8 @@ set "RELEASE=%RELEASE_BUILD%"
set "RELEASE_TYPE=unknown"
set "VERSION="
set "COMMIT_HASH="
set "BUILD_UNIXTIME="
set "BUILD_DATE="
set "BUILD_UNIXTIME=%BUILD_UNIXTIME%"
set "BUILD_DATE=%BUILD_DATE%"
set "PACKAGE_FILENAME="
for /f %%a in ('"prompt $E$S & echo on & for %%b in (1) do rem"') do set "ESC=%%a"
@@ -112,9 +112,15 @@ 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"
call :set_unixtime BUILD_UNIXTIME
call :set_date BUILD_DATE
for /f %%x in ('git rev-parse --short^=7 HEAD') do set "COMMIT_HASH=%%x"
if "%BUILD_UNIXTIME%"=="" (
call :set_unixtime BUILD_UNIXTIME
)
if "%BUILD_DATE%"=="" (
call :set_date BUILD_DATE
)
:main
if "%TYPE%"=="backend" call :build_backend & goto :end
@@ -261,7 +267,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
+237
View File
@@ -0,0 +1,237 @@
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 = $env:BUILD_UNIXTIME
$script:BuildDate = $env:BUILD_DATE
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
if (-not $BuildUnixTime) {
$script:BuildUnixTime = [int][double]::Parse((Get-Date -UFormat %s))
}
if (-not $BuildDate) {
$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
+13 -5
View File
@@ -8,7 +8,8 @@ RELEASE=${RELEASE_BUILD:-"0"}
RELEASE_TYPE="unknown"
VERSION=""
COMMIT_HASH=""
BUILD_UNIXTIME=""
BUILD_UNIXTIME="${BUILD_UNIXTIME}"
BUILD_DATE="${BUILD_DATE}"
PACKAGE_FILENAME=""
DOCKER_TAG=""
@@ -117,8 +118,15 @@ 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)"
BUILD_UNIXTIME="$(date '+%s')"
COMMIT_HASH="$(git rev-parse --short=7 HEAD)"
if [ -z "$BUILD_UNIXTIME" ]; then
BUILD_UNIXTIME="$(date '+%s')"
fi
if [ -z "$BUILD_DATE" ]; then
BUILD_DATE="$(date '+%Y%m%d')"
fi
}
build_backend() {
@@ -203,7 +211,7 @@ build_package() {
package_file_name="$VERSION";
if [ "$RELEASE" = "0" ]; then
package_file_name="$package_file_name-$(date '+%Y%m%d')"
package_file_name="$package_file_name-$BUILD_DATE"
fi
package_file_name="ezbookkeeping-$package_file_name-$(arch).tar.gz"
@@ -237,7 +245,7 @@ build_docker() {
docker_tag="$VERSION"
if [ "$RELEASE" = "0" ]; then
docker_tag="SNAPSHOT-$(date '+%Y%m%d')";
docker_tag="SNAPSHOT-$BUILD_DATE";
fi
docker_tag="ezbookkeeping:$docker_tag"
+24
View File
@@ -101,6 +101,14 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error {
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTagGroup))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag group table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTag))
if err != nil {
@@ -149,5 +157,21 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error {
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user application cloud settings table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserExternalAuth))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user external auth table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.InsightsExplorer))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] insights explorer table maintained successfully")
return nil
}
+51 -5
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 {
@@ -152,11 +162,47 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
return config
}
clonedConfig.DatabaseConfig.DatabasePassword = "****"
clonedConfig.SMTPConfig.SMTPPasswd = "****"
clonedConfig.MinIOConfig.SecretAccessKey = "****"
clonedConfig.SecretKey = "****"
clonedConfig.AmapApplicationSecret = "****"
if clonedConfig.DatabaseConfig.DatabasePassword != "" {
clonedConfig.DatabaseConfig.DatabasePassword = "****"
}
if clonedConfig.SMTPConfig.SMTPPasswd != "" {
clonedConfig.SMTPConfig.SMTPPasswd = "****"
}
if clonedConfig.MinIOConfig.SecretAccessKey != "" {
clonedConfig.MinIOConfig.SecretAccessKey = "****"
}
if clonedConfig.SecretKey != "" {
clonedConfig.SecretKey = "****"
}
if clonedConfig.AmapApplicationSecret != "" {
clonedConfig.AmapApplicationSecret = "****"
}
if clonedConfig.WebDAVConfig != nil && clonedConfig.WebDAVConfig.Password != "" {
clonedConfig.WebDAVConfig.Password = "****"
}
if clonedConfig.ReceiptImageRecognitionLLMConfig != nil {
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey != "" {
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey = "****"
}
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey != "" {
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
}
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey != "" {
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
}
}
if clonedConfig.OAuth2ClientSecret != "" {
clonedConfig.OAuth2ClientSecret = "****"
}
return clonedConfig
}
+53 -5
View File
@@ -264,7 +264,26 @@ var UserData = &cli.Command{
Name: "type",
Aliases: []string{"t"},
Required: false,
Usage: "Specific token type, supports \"normal\" and \"mcp\", default is \"normal\"",
Usage: "Specific token type, supports \"api\" and \"mcp\", default is \"api\"",
},
&cli.Int64Flag{
Name: "expiresInSeconds",
Aliases: []string{"e"},
Required: true,
Usage: "Token expiration time in seconds (0 - 4294967295, 0 means no expiration).",
},
},
},
{
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",
},
},
},
@@ -709,17 +728,23 @@ func createNewUserToken(c *core.CliContext) error {
username := c.String("username")
tokenType := c.String("type")
expiresInSeconds := c.Int64("expiresInSeconds")
if tokenType == "" {
tokenType = "normal"
tokenType = "api"
}
if tokenType != "normal" && tokenType != "mcp" {
if tokenType != "api" && tokenType != "mcp" {
log.CliErrorf(c, "[user_data.createNewUserToken] token type is invalid")
return nil
}
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username, tokenType)
if expiresInSeconds < 0 || expiresInSeconds > 4294967295 {
log.CliErrorf(c, "[user_data.createNewUserToken] expiresInSeconds is out of range (0 - 4294967295)")
return nil
}
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username, tokenType, expiresInSeconds)
if err != nil {
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
@@ -732,6 +757,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)
@@ -913,15 +958,18 @@ func printUserInfo(user *models.User) {
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)
+2 -2
View File
@@ -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",
+98 -18
View File
@@ -15,6 +15,7 @@ import (
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/api"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/cron"
"github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -72,6 +73,13 @@ func startWebServer(c *core.CliContext) error {
return err
}
err = oauth2.InitializeOAuth2Provider(config)
if err != nil {
log.BootErrorf(c, "[webserver.startWebServer] initializes oauth 2.0 provider failed, because %s", err.Error())
return err
}
err = cron.InitializeCronJobSchedulerContainer(c, config, true)
if err != nil {
@@ -79,7 +87,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)
@@ -104,9 +112,11 @@ func startWebServer(c *core.CliContext) error {
_ = v.RegisterValidation("notBlank", validators.NotBlank)
_ = v.RegisterValidation("validUsername", validators.ValidUsername)
_ = v.RegisterValidation("validEmail", validators.ValidEmail)
_ = v.RegisterValidation("validNickname", validators.ValidNickname)
_ = v.RegisterValidation("validCurrency", validators.ValidCurrency)
_ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor)
_ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter)
_ = v.RegisterValidation("validTagFilter", validators.ValidTagFilter)
_ = v.RegisterValidation("validFiscalYearStart", validators.ValidateFiscalYearStart)
}
@@ -167,7 +177,7 @@ func startWebServer(c *core.CliContext) error {
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
avatarRoute := router.Group("/avatar")
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
{
avatarRoute.GET("/:fileName", bindImage(api.Users.UserGetAvatarHandler))
}
@@ -175,7 +185,7 @@ func startWebServer(c *core.CliContext) error {
if config.EnableTransactionPictures {
pictureRoute := router.Group("/pictures")
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
{
pictureRoute.GET("/:fileName", bindImage(api.TransactionPictures.TransactionPictureGetHandler))
}
@@ -184,7 +194,7 @@ func startWebServer(c *core.CliContext) error {
router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
proxyRoute := router.Group("/proxy")
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
{
if config.EnableMapDataFetchProxy {
if config.MapProvider == settings.OpenStreetMapProvider ||
@@ -208,7 +218,7 @@ func startWebServer(c *core.CliContext) error {
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
amapApiProxyRoute := router.Group("/_AMapService")
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie))
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie(config)))
{
amapApiProxyRoute.GET("/*action", bindProxy(api.AmapApis.AmapApiProxyHandler))
}
@@ -226,7 +236,7 @@ func startWebServer(c *core.CliContext) error {
mcpRoute.Use(bindMiddleware(middlewares.RequestId(config)))
mcpRoute.Use(bindMiddleware(middlewares.RequestLog))
mcpRoute.Use(bindMiddleware(middlewares.MCPServerIpLimit(config)))
mcpRoute.Use(bindMiddleware(middlewares.JWTMCPAuthorization))
mcpRoute.Use(bindMiddleware(middlewares.JWTMCPAuthorization(config)))
{
mcpRoute.POST("", bindJSONRPCApi(map[string]core.JSONRPCApiHandlerFunc{
"initialize": api.ModelContextProtocols.InitializeHandler,
@@ -242,23 +252,43 @@ func startWebServer(c *core.CliContext) error {
}
}
if config.EnableOAuth2Login {
oauth2Route := router.Group("/oauth2")
oauth2Route.Use(bindMiddleware(middlewares.RequestId(config)))
oauth2Route.Use(bindMiddleware(middlewares.RequestLog))
{
oauth2Route.GET("/login", bindRedirect(api.OAuth2Authentications.LoginHandler))
oauth2Route.GET("/callback", bindRedirect(api.OAuth2Authentications.CallbackHandler))
}
}
apiRoute := router.Group("/api")
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
apiRoute.Use(bindMiddleware(middlewares.RequestLog))
{
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
if config.EnableInternalAuth {
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
}
if config.EnableTwoFactor {
if config.EnableInternalAuth && config.EnableTwoFactor {
twoFactorRoute := apiRoute.Group("/2fa")
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization))
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization(config)))
{
twoFactorRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeHandler, config))
twoFactorRoute.POST("/recovery.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler, config))
}
}
if config.EnableUserRegister {
if config.EnableOAuth2Login {
oauth2Route := apiRoute.Group("/oauth2")
oauth2Route.Use(bindMiddleware(middlewares.JWTOAuth2CallbackAuthorization(config)))
{
oauth2Route.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.OAuth2CallbackAuthorizeHandler, config))
}
}
if config.EnableInternalAuth && config.EnableUserRegister {
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
}
@@ -266,17 +296,17 @@ func startWebServer(c *core.CliContext) error {
apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler))
emailVerifyRoute := apiRoute.Group("/verify_email")
emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization))
emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization(config)))
{
emailVerifyRoute.POST("/by_token.json", bindApi(api.Users.UserEmailVerifyHandler))
}
}
if config.EnableUserForgetPassword {
if config.EnableInternalAuth && config.EnableUserForgetPassword {
apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler))
resetPasswordRoute := apiRoute.Group("/forget_password/reset")
resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization))
resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization(config)))
{
resetPasswordRoute.POST("/by_token.json", bindApi(api.ForgetPasswords.UserResetPasswordHandler))
}
@@ -285,10 +315,11 @@ func startWebServer(c *core.CliContext) error {
apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config))
apiV1Route := apiRoute.Group("/v1")
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization))
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization(config)))
{
// Tokens
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
apiV1Route.POST("/tokens/generate/api.json", bindApi(api.Tokens.TokenGenerateAPIHandler))
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))
@@ -307,6 +338,12 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
}
// External Authentications
if config.EnableOAuth2Login {
apiV1Route.GET("/users/external_auth/list.json", bindApi(api.UserExternalAuths.ExternalAuthListHanlder))
apiV1Route.POST("/users/external_auth/unlink.json", bindApi(api.UserExternalAuths.UnlinkExternalAuthHandler))
}
// 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))
@@ -323,7 +360,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))
@@ -344,12 +383,16 @@ func startWebServer(c *core.CliContext) error {
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/list/all.json", bindApi(api.Transactions.TransactionListAllHandler))
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/statistics/asset_trends.json", bindApi(api.Transactions.TransactionStatisticsAssetTrendsHandler))
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
apiV1Route.POST("/transactions/move/all.json", bindApi(api.Transactions.TransactionMoveAllBetweenAccountsHandler))
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
if config.EnableDataImport {
@@ -375,6 +418,14 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/transaction/categories/move.json", bindApi(api.TransactionCategories.CategoryMoveHandler))
apiV1Route.POST("/transaction/categories/delete.json", bindApi(api.TransactionCategories.CategoryDeleteHandler))
// Transaction Tag Groups
apiV1Route.GET("/transaction/tags/groups/list.json", bindApi(api.TransactionTagGroups.TagGroupListHandler))
apiV1Route.GET("/transaction/tags/groups/get.json", bindApi(api.TransactionTagGroups.TagGroupGetHandler))
apiV1Route.POST("/transaction/tags/groups/add.json", bindApi(api.TransactionTagGroups.TagGroupCreateHandler))
apiV1Route.POST("/transaction/tags/groups/modify.json", bindApi(api.TransactionTagGroups.TagGroupModifyHandler))
apiV1Route.POST("/transaction/tags/groups/move.json", bindApi(api.TransactionTagGroups.TagGroupMoveHandler))
apiV1Route.POST("/transaction/tags/groups/delete.json", bindApi(api.TransactionTagGroups.TagGroupDeleteHandler))
// Transaction Tags
apiV1Route.GET("/transaction/tags/list.json", bindApi(api.TransactionTags.TagListHandler))
apiV1Route.GET("/transaction/tags/get.json", bindApi(api.TransactionTags.TagGetHandler))
@@ -394,6 +445,22 @@ 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))
// Insights Explorers
apiV1Route.GET("/insights/explorers/list.json", bindApi(api.InsightsExplorers.InsightsExplorerListHandler))
apiV1Route.GET("/insights/explorers/get.json", bindApi(api.InsightsExplorers.InsightsExplorerGetHandler))
apiV1Route.POST("/insights/explorers/add.json", bindApi(api.InsightsExplorers.InsightsExplorerCreateHandler))
apiV1Route.POST("/insights/explorers/modify.json", bindApi(api.InsightsExplorers.InsightsExplorerModifyHandler))
apiV1Route.POST("/insights/explorers/hide.json", bindApi(api.InsightsExplorers.InsightsExplorerHideHandler))
apiV1Route.POST("/insights/explorers/move.json", bindApi(api.InsightsExplorers.InsightsExplorerMoveHandler))
apiV1Route.POST("/insights/explorers/delete.json", bindApi(api.InsightsExplorers.InsightsExplorerDeleteHandler))
// 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))
@@ -433,6 +500,19 @@ func bindMiddleware(fn core.MiddlewareHandlerFunc) gin.HandlerFunc {
}
}
func bindRedirect(fn core.RedirectHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
url, err := fn(c)
if err != nil {
utils.PrintJsonErrorResult(c, err)
} else {
c.Redirect(http.StatusFound, url)
}
}
}
func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
@@ -521,7 +601,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)
}
})
}
@@ -534,7 +614,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)
}
}
}
@@ -547,7 +627,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)
}
}
}
+154 -17
View File
@@ -1,7 +1,4 @@
[global]
# Application instance name
app_name = ezBookkeeping
# Either "production", "development"
mode = production
@@ -18,7 +15,7 @@ http_port = 8080
# The domain name used to access ezBookkeeping
domain = localhost
# The full url used to access ezBookkeeping in browser
# The full url used to access ezBookkeeping in browser, supports placeholders: %(protocol)s, %(domain)s, %(http_port)s
root_url = %(protocol)s://%(domain)s:%(http_port)s/
# https certification and its key file
@@ -37,6 +34,9 @@ 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
@@ -115,7 +115,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)
@@ -139,6 +139,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
@@ -168,9 +244,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
@@ -187,14 +260,81 @@ email_verify_token_expired_time = 3600
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
password_reset_token_expired_time = 3600
# Set to true to enable API token generation
enable_api_token = false
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
max_failures_per_ip_per_minute = 5
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
max_failures_per_user_per_minute = 5
# Add X-Request-Id header to response to track user request or error, default is true
request_id_header = true
[auth]
# Set to true to enable internal authentication
enable_internal_auth = true
# Set to true to enable OAuth 2.0 authentication
enable_oauth2_auth = false
# For "internal" authentication only, set to true to enable two-factor authorization
enable_two_factor = true
# For "internal" authentication only, set to true to allow users to reset password
enable_forget_password = true
# For "internal" authentication only, set to true to require email must be verified when use forget password
forget_password_require_email_verify = false
# For "oauth2" authentication only, OAuth 2.0 provider, supports "oidc", "nextcloud", "gitea" and "github" currently
oauth2_provider =
# For "oauth2" authentication only, OAuth 2.0 client ID
oauth2_client_id =
# For "oauth2" authentication only, OAuth 2.0 client secret
oauth2_client_secret =
# For "oauth2" authentication only, OAuth 2.0 provider user identifier claim name, supports "email" and "username", default is "email"
oauth2_user_identifier = email
# For "oauth2" authentication only, set to true to use PKCE
oauth2_use_pkce = false
# For "oauth2" authentication only, if the user returned by OAuth 2.0 is not registered, automatically create a new user (requires "enable_register" to be set to true)
oauth2_auto_register = true
# For "oauth2" authentication only, OAuth 2.0 state expired seconds (60 - 4294967295), default is 300 (5 minutes)
oauth2_state_expired_time = 300
# For "oauth2" authentication only, requesting OAuth 2.0 api timeout (0 - 4294967295 milliseconds)
# Set to 0 to disable timeout for requesting OAuth 2.0 api, default is 10000 (10 seconds)
oauth2_request_timeout = 10000
# For "oauth2" authentication only, proxy for ezbookkeeping server requesting OAuth 2.0 api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
oauth2_proxy = system
# For "oauth2" authentication only, set to true to skip tls verification when request OAuth 2.0 api
oauth2_skip_tls_verify = false
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, OIDC provider issuer url. Make sure the ".well-known" directory is available under this path. For example, if it's set to "https://auth.example.com", the discovery URL should be "https://auth.example.com/.well-known/openid-configuration".
oidc_provider_base_url =
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, set to true to check whether the issuer url in the discovery response matches the above "oidc_provider_base_url"
oidc_provider_check_issuer_url = true
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, set to true to replace the text "Connect ID" in the "Log in with Connect ID" button with the below custom provider name
enable_oidc_display_name = false
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, the custom provider name to replace the text in the "Log in with Connect ID" button, it supports multi-language configuration
# Add an underscore and a language tag after the setting key to configure the display name in that language
# For example, oidc_custom_display_name_zh_hans means the display name in Chinese (Simplified)
oidc_custom_display_name =
# For "oauth2" authentication and "nextcloud" OAuth 2.0 provider only, Nextcloud base url, e.g. "https://cloud.example.org/"
nextcloud_base_url =
# For "oauth2" authentication and "gitea" OAuth 2.0 provider only, Gitea base url, e.g. "https://git.example.com/"
gitea_base_url =
[user]
# Set to true to allow users to register account by themselves
@@ -206,12 +346,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
@@ -245,6 +379,10 @@ max_user_avatar_size = 1048576
# 11: Clear All Data
# 12: Sync Application Settings
# 13: MCP (Model Context Protocol) Access
# 14: Create Transactions from AI Image Recognition
# 15: OAuth 2.0 Login
# 16: Unlink Third-party Login
# 17: Generate API Token
default_feature_restrictions =
[data]
@@ -371,7 +509,6 @@ custom_map_tile_server_default_zoom_level = 14
# "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
+70
View File
@@ -0,0 +1,70 @@
{
"code": [
"jiangshengwu",
"vigdail",
"f97",
"Miguelonlonlon",
"seb26",
"nktlitvinenko",
"lvdou-bing",
"dshemin",
"lucdsouza",
"OuIChien"
],
"translators": {
"de": [
"chrgm"
],
"en": [],
"es": [
"Miguelonlonlon",
"abrugues",
"AndresTeller",
"diegofercri"
],
"fr": [
"brieucdlf"
],
"it": [
"waron97"
],
"ja": [
"tkymmm"
],
"kn": [
"Darshanbm05"
],
"ko": [
"overworks"
],
"nl": [
"automagics"
],
"pt-BR": [
"thecodergus"
],
"ru": [
"artegoser"
],
"sl": [
"thehijacker"
],
"ta": [
"hhharsha36"
],
"th": [
"natthavat28"
],
"tr": [
"aydnykn"
],
"uk": [
"nktlitvinenko"
],
"vi": [
"f97"
],
"zh-Hans": [],
"zh-Hant": []
}
}
+34
View File
@@ -0,0 +1,34 @@
variable "DEFAULT_TAG" {
default = "ezbookkeeping:local"
}
// Special target: https://github.com/docker/metadata-action#bake-definition
target "docker-metadata-action" {
tags = ["${DEFAULT_TAG}"]
}
// Default target if none specified
group "default" {
targets = ["image-local"]
}
target "image" {
inherits = ["docker-metadata-action"]
context = "./"
dockerfile = "Dockerfile"
}
target "image-local" {
inherits = ["image"]
output = ["type=docker"]
}
target "image-all" {
inherits = ["image"]
platforms = [
"linux/amd64",
"linux/arm64",
"linux/arm/v7",
"linux/arm/v6"
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=ezBookkeeping, a lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features.
Description=ezBookkeeping, a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.
After=syslog.target
After=network.target
After=mariadb.service mysqld.service postgresql.service
+1 -1
View File
@@ -32,7 +32,7 @@ func main() {
cmd := &cli.Command{
Name: "ezBookkeeping",
Usage: "A lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features.",
Usage: "A lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.",
Version: GetFullVersion(),
Commands: []*cli.Command{
cmd.WebServer,
+43 -32
View File
@@ -1,34 +1,37 @@
module github.com/mayswind/ezbookkeeping
go 1.24
go 1.25
require (
github.com/boombuler/barcode v1.0.2
github.com/boombuler/barcode v1.1.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
github.com/gin-contrib/cache v1.4.0
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.2
github.com/go-playground/validator/v10 v10.26.0
github.com/go-sql-driver/mysql v1.9.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gin-contrib/cache v1.4.1
github.com/gin-contrib/gzip v1.2.5
github.com/gin-gonic/gin v1.11.0
github.com/go-co-op/gocron/v2 v2.18.2
github.com/go-playground/validator/v10 v10.28.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.28
github.com/minio/minio-go/v7 v7.0.92
github.com/mattn/go-sqlite3 v1.14.32
github.com/minio/minio-go/v7 v7.0.97
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.3.3
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v3 v3.6.1
github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/xuri/excelize/v2 v2.9.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/text v0.25.0
github.com/xuri/excelize/v2 v2.10.0
golang.org/x/crypto v0.46.0
golang.org/x/net v0.48.0
golang.org/x/oauth2 v0.34.0
golang.org/x/text v0.32.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.11
)
require (
@@ -36,44 +39,49 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/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.5 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/iasm v0.2.0 // 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.9 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/redigo v1.9.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
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.1 // indirect
github.com/minio/crc64nvme v1.1.0 // 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
@@ -82,17 +90,20 @@ require (
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/tiendc/go-deepcopy v1.7.1 // 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.3.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.1 // indirect
golang.org/x/arch v0.17.0 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/sys v0.33.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+82 -61
View File
@@ -4,19 +4,20 @@ 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/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/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/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/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/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=
@@ -24,9 +25,11 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
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.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/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=
@@ -40,32 +43,36 @@ 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.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.0 h1:d1FUqCE2+gJQKT0vJjr7jMn1htW9+cypk5oF7aoQcmE=
github.com/gin-contrib/cache v1.4.0/go.mod h1:6d0UAPedInkublPl/uJUB4bqwsEgJI1y5QGszhqnyxg=
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/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
github.com/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.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4=
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-co-op/gocron/v2 v2.18.2 h1:+5VU41FUXPWSPKLXZQ/77SGzUiPCcakU0v7ENc2H20Q=
github.com/go-co-op/gocron/v2 v2.18.2/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-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.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
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.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/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=
@@ -87,8 +94,10 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
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.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
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=
@@ -101,22 +110,21 @@ 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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/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.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
github.com/minio/crc64nvme v1.1.0/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.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
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=
@@ -125,12 +133,16 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
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.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
@@ -153,50 +165,59 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/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/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.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=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
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=
@@ -215,5 +236,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.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
+3405 -2462
View File
File diff suppressed because it is too large Load Diff
+43 -35
View File
@@ -1,6 +1,6 @@
{
"name": "ezbookkeeping",
"version": "0.10.0",
"version": "1.3.2",
"private": true,
"repository": {
"type": "git",
@@ -20,64 +20,72 @@
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@vuepic/vue-datepicker": "^11.0.2",
"axios": "^1.9.0",
"@vuepic/vue-datepicker": "^12.1.0",
"axios": "^1.13.2",
"cbor-js": "^0.1.0",
"chardet": "^2.1.1",
"clipboard": "^2.0.11",
"crypto-js": "^4.2.0",
"dom7": "^4.0.6",
"echarts": "^5.6.0",
"framework7": "^8.3.4",
"echarts": "^6.0.0",
"framework7": "^9.0.2",
"framework7-icons": "^5.0.5",
"framework7-vue": "^8.3.4",
"framework7-vue": "^9.0.2",
"jalaali-js": "^1.2.8",
"leaflet": "^1.9.4",
"line-awesome": "^1.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"pinia": "^3.0.2",
"pinia": "^3.0.4",
"register-service-worker": "^1.7.2",
"skeleton-elements": "^4.0.1",
"swiper": "^10.2.0",
"swiper": "^12.0.3",
"ua-parser-js": "^1.0.39",
"vue": "^3.5.16",
"vue-echarts": "^7.0.3",
"vue-i18n": "^11.1.5",
"vue-router": "^4.5.1",
"vue": "^3.5.25",
"vue-echarts": "^8.0.1",
"vue-i18n": "^11.2.2",
"vue-router": "^4.6.4",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.8.7"
"vuetify": "^3.11.3"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@tsconfig/node22": "^22.0.2",
"@jest/globals": "^30.2.0",
"@tsconfig/node24": "^24.0.3",
"@types/cbor-js": "^0.1.1",
"@types/crypto-js": "^4.2.2",
"@types/git-rev-sync": "^2.0.2",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.29",
"@types/jalaali-js": "^1.2.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.1.0",
"@types/ua-parser-js": "^0.7.39",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",
"cross-env": "^7.0.3",
"eslint": "^9.28.0",
"eslint-plugin-vue": "^10.1.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.6.2",
"git-rev-sync": "^3.0.2",
"jest": "^29.7.0",
"postcss-preset-env": "^10.2.0",
"sass": "^1.89.1",
"ts-jest": "^29.3.4",
"jest": "^30.2.0",
"postcss-preset-env": "^10.5.0",
"sass": "^1.96.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-checker": "^0.9.3",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-vuetify": "^2.1.1",
"vue-tsc": "^2.2.10"
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vite-plugin-checker": "^0.12.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-vuetify": "^2.1.2",
"vue-tsc": "^3.1.8"
},
"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"
]
}
+27 -16
View File
@@ -150,10 +150,10 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[accounts.AccountCreateHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[accounts.AccountCreateHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -278,7 +278,7 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
}
}
err = a.accounts.CreateAccounts(c, mainAccount, accountCreateReq.BalanceTime, childrenAccounts, childrenAccountBalanceTimes, utcOffset)
err = a.accounts.CreateAccounts(c, mainAccount, accountCreateReq.BalanceTime, childrenAccounts, childrenAccountBalanceTimes, clientTimezone)
if err != nil {
log.Errorf(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
@@ -315,10 +315,10 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
return nil, errs.ErrAccountIdInvalid
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[accounts.AccountModifyHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[accounts.AccountModifyHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -437,6 +437,17 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
if toUpdateAccount != nil {
if toUpdateAccount.Category != mainAccount.Category {
maxOrderId, err := a.accounts.GetMaxDisplayOrder(c, uid, toUpdateAccount.Category)
if err != nil {
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
toUpdateAccount.DisplayOrder = maxOrderId + 1
}
anythingUpdate = true
toUpdateAccounts = append(toUpdateAccounts, toUpdateAccount)
}
@@ -521,7 +532,7 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
}
}
err = a.accounts.ModifyAccounts(c, mainAccount, toUpdateAccounts, toAddAccounts, toAddAccountBalanceTimes, toDeleteAccountIds, utcOffset)
err = a.accounts.ModifyAccounts(c, mainAccount, toUpdateAccounts, toAddAccounts, toAddAccountBalanceTimes, toDeleteAccountIds, clientTimezone)
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())
@@ -542,7 +553,6 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
account.Type = oldAccount.Type
account.ParentAccountId = oldAccount.ParentAccountId
account.DisplayOrder = oldAccount.DisplayOrder
account.Currency = oldAccount.Currency
account.Balance = oldAccount.Balance
@@ -762,15 +772,16 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
}
newAccount := &models.Account{
AccountId: oldAccount.AccountId,
Uid: uid,
Name: accountModifyReq.Name,
Category: accountModifyReq.Category,
Icon: accountModifyReq.Icon,
Color: accountModifyReq.Color,
Comment: accountModifyReq.Comment,
Extend: newAccountExtend,
Hidden: accountModifyReq.Hidden,
AccountId: oldAccount.AccountId,
Uid: uid,
Name: accountModifyReq.Name,
DisplayOrder: oldAccount.DisplayOrder,
Category: accountModifyReq.Category,
Icon: accountModifyReq.Icon,
Color: accountModifyReq.Color,
Comment: accountModifyReq.Comment,
Extend: newAccountExtend,
Hidden: accountModifyReq.Hidden,
}
if newAccount.Name != oldAccount.Name ||
+201 -3
View File
@@ -1,6 +1,9 @@
package api
import (
"encoding/json"
"errors"
"github.com/pquerna/otp/totp"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
@@ -22,6 +25,7 @@ type AuthorizationsApi struct {
userAppCloudSettings *services.UserApplicationCloudSettingsService
tokens *services.TokenService
twoFactorAuthorizations *services.TwoFactorAuthorizationService
userExternalAuths *services.UserExternalAuthService
}
// Initialize a authorization api singleton instance
@@ -48,11 +52,16 @@ var (
userAppCloudSettings: services.UserApplicationCloudSettings,
tokens: services.Tokens,
twoFactorAuthorizations: services.TwoFactorAuthorizations,
userExternalAuths: services.UserExternalAuths,
}
)
// AuthorizeHandler verifies and authorizes current login request
func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableInternalAuth {
return nil, errs.ErrCannotLoginByPassword
}
var credential models.UserLoginRequest
err := c.ShouldBindJSON(&credential)
@@ -141,6 +150,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
}
c.SetTokenClaims(claims)
c.SetTokenContext("")
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
@@ -151,7 +161,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
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)
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logged in, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
authResp := a.getAuthResponse(c, token, twoFactorEnable, user, applicationCloudSettingSlice)
return authResp, nil
@@ -159,6 +169,10 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableInternalAuth {
return nil, errs.ErrCannotLoginByPassword
}
var credential models.TwoFactorLoginRequest
err := c.ShouldBindJSON(&credential)
@@ -198,7 +212,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
@@ -228,6 +242,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
c.SetTextualToken(token)
c.SetTokenClaims(claims)
c.SetTokenContext("")
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
@@ -246,6 +261,10 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableInternalAuth {
return nil, errs.ErrCannotLoginByPassword
}
var credential models.TwoFactorRecoveryCodeLoginRequest
err := c.ShouldBindJSON(&credential)
@@ -276,7 +295,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
@@ -322,6 +341,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
c.SetTextualToken(token)
c.SetTokenClaims(claims)
c.SetTokenContext("")
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
@@ -338,6 +358,184 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
return authResp, nil
}
// OAuth2CallbackAuthorizeHandler verifies and authorizes current OAuth 2.0 callback login
func (a *AuthorizationsApi) OAuth2CallbackAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableOAuth2Login {
return nil, errs.ErrOAuth2NotEnabled
}
var credential models.OAuth2CallbackLoginRequest
err := c.ShouldBindJSON(&credential)
if err != nil {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
var tokenContext models.OAuth2CallbackTokenContext
err = json.Unmarshal([]byte(c.GetTokenContext()), &tokenContext)
if err != nil {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] parse token context failed, because %s", err.Error())
return nil, errs.ErrOperationFailed
}
if !tokenContext.ExternalAuthType.IsValid() {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] external auth type \"%s\" is invalid", tokenContext.ExternalAuthType)
return nil, errs.ErrInvalidOAuth2Provider
}
uid := c.GetCurrentUid()
err = a.CheckFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
oldTokenClaims := c.GetTokenClaims()
if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY {
if credential.Password == "" {
return nil, errs.ErrPasswordIsEmpty
}
if !a.users.IsPasswordEqualsUserPassword(credential.Password, user) {
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
if failureCheckErr != nil {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot login for user \"uid:%d\", because %s", user.Uid, failureCheckErr.Error())
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
}
return nil, errs.ErrUserPasswordWrong
}
if a.CurrentConfig().EnableTwoFactor {
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
if err != nil && !errors.Is(err, errs.ErrTwoFactorIsNotEnabled) {
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to check two-factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrSystemError)
}
if twoFactorSetting != nil {
if credential.Passcode == "" {
return nil, errs.ErrPasscodeEmpty
}
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
err = a.CheckAndIncreaseFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
return nil, errs.ErrPasscodeInvalid
}
}
}
userExternalAuth := &models.UserExternalAuth{
Uid: user.Uid,
ExternalAuthType: tokenContext.ExternalAuthType,
ExternalUsername: tokenContext.ExternalUsername,
ExternalEmail: tokenContext.ExternalEmail,
}
err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth)
if err != nil {
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user external auth has been created for user \"uid:%d\"", user.Uid)
} else if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK {
_, err = a.userExternalAuths.GetUserExternalAuthByUid(c, uid, tokenContext.ExternalAuthType)
if err != nil {
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user external auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrUserExternalAuthNotFound)
}
} else {
return nil, errs.ErrSystemError
}
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
if err != nil {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
}
var token string
var claims *core.UserTokenClaims
if credential.Token != "" {
_, claims, _, err = a.tokens.ParseToken(c, credential.Token)
if err != nil {
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to parse token, because %s", err.Error())
return nil, errs.ErrInvalidToken
}
if claims.Uid != user.Uid {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] oauth 2.0 user \"uid:%d\" does not match current user \"uid:%d\"", user.Uid, claims.Uid)
token = ""
claims = nil
} else {
token = credential.Token
}
}
if token == "" {
token, claims, err = a.tokens.CreateToken(c, user)
if err != nil {
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
c.SetTokenContext("")
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
if err != nil {
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] 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.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has logged in, token will be expired at %d", user.Uid, claims.ExpiresAt)
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, applicationCloudSettings *models.ApplicationCloudSettingSlice) *models.AuthResponse {
return &models.AuthResponse{
Token: token,
+21 -10
View File
@@ -3,6 +3,7 @@ package api
import (
"fmt"
"sort"
"time"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
@@ -23,7 +24,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
@@ -53,15 +54,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
@@ -72,15 +73,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
@@ -91,15 +92,15 @@ 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
@@ -113,6 +114,11 @@ func (a *ApiUsingDuplicateChecker) GetSubmissionRemark(checkerType duplicatechec
return a.container.GetSubmissionRemark(checkerType, uid, identification)
}
// SetSubmissionRemarkWithCustomExpiration saves the identification and remark by the current duplicate checker with custom expiration time
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkWithCustomExpiration(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) {
a.container.SetSubmissionRemarkWithCustomExpiration(checkerType, uid, identification, remark, expiration)
}
// 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 {
@@ -120,6 +126,11 @@ func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkIfEnable(checkerType dupli
}
}
// RemoveSubmissionRemark removes the identification and remark by the current duplicate checker
func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemark(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) {
a.container.RemoveSubmissionRemark(checkerType, uid, identification)
}
// 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 {
+156 -35
View File
@@ -2,6 +2,7 @@ package api
import (
"fmt"
"math"
"strings"
"time"
@@ -15,6 +16,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const pageCountForClearTransactions = 1000
const pageCountForDataExport = 1000
// DataManagementsApi represents data management api
@@ -26,9 +28,11 @@ type DataManagementsApi struct {
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
tagGroups *services.TransactionTagGroupService
pictures *services.TransactionPictureService
templates *services.TransactionTemplateService
userCustomExchangeRates *services.UserCustomExchangeRatesService
insightsExploreres *services.InsightsExplorerService
}
// Initialize a data management api singleton instance
@@ -43,9 +47,11 @@ var (
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
tagGroups: services.TransactionTagGroups,
pictures: services.TransactionPictures,
templates: services.TransactionTemplates,
userCustomExchangeRates: services.UserCustomExchangeRates,
insightsExploreres: services.InsightsExplorers,
}
)
@@ -97,6 +103,13 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
return nil, errs.ErrOperationFailed
}
totalInsightsExplorerCount, err := a.insightsExploreres.GetTotalInsightsExplorersCountByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total insights explorer count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionTemplateCount, err := a.templates.GetTotalNormalTemplateCountByUid(c, uid)
if err != nil {
@@ -117,6 +130,7 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
TotalTransactionTagCount: totalTransactionTagCount,
TotalTransactionCount: totalTransactionCount,
TotalTransactionPictureCount: totalTransactionPictureCount,
TotalInsightsExplorerCount: totalInsightsExplorerCount,
TotalTransactionTemplateCount: totalTransactionTemplateCount,
TotalScheduledTransactionCount: totalScheduledTransactionCount,
}
@@ -124,13 +138,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)
}
@@ -139,7 +153,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
@@ -156,39 +170,148 @@ 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)
}
err = a.tagGroups.DeleteAllTagGroups(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tag groups, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
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.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
err = a.insightsExploreres.DeleteAllInsightsExplorers(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all insights explorers, 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
}
@@ -201,17 +324,15 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
err := c.ShouldBindQuery(&exportTransactionDataReq)
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[data_managements.getExportedFileContent] parse request failed, because %s", err.Error())
return nil, "", errs.NewIncompleteOrIncorrectSubmissionError(err)
}
timezone := time.Local
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
} else {
timezone = time.FixedZone("Client Timezone", int(utcOffset)*60)
log.Warnf(c, "[data_managements.getExportedFileContent] cannot get client timezone, because %s", err.Error())
clientTimezone = time.Local
}
uid := c.GetCurrentUid()
@@ -219,7 +340,7 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[data_managements.ExportDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[data_managements.getExportedFileContent] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, "", errs.ErrUserNotFound
@@ -232,28 +353,28 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
categories, err := a.categories.GetAllCategoriesByUid(c, uid, 0, -1)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tags, err := a.tags.GetAllTagsByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tagIndexes, err := a.tags.GetAllTagIdsMapOfAllTransactions(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
@@ -264,30 +385,30 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
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())
log.Warnf(c, "[data_managements.getExportedFileContent] 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())
log.Warnf(c, "[data_managements.getExportedFileContent] get transaction category error, because %s", err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
var allTagIds []int64
noTags := exportTransactionDataReq.TagIds == "none"
noTags := exportTransactionDataReq.TagFilter == models.TransactionNoTagFilterValue
var tagFilters []*models.TransactionTagFilter
if !noTags {
allTagIds, err = a.tags.GetTagIds(exportTransactionDataReq.TagIds)
tagFilters, err = models.ParseTransactionTagFilter(exportTransactionDataReq.TagFilter)
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] get transaction tag ids error, because %s", err.Error())
log.Warnf(c, "[data_managements.getExportedFileContent] parse transaction tag filters error, because %s", err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
}
maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
maxTransactionTime := int64(math.MaxInt64)
minTransactionTime := int64(0)
if exportTransactionDataReq.MaxTime > 0 {
@@ -298,10 +419,10 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(exportTransactionDataReq.MinTime)
}
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, exportTransactionDataReq.TagFilterType, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, pageCountForDataExport, true)
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, pageCountForDataExport, true)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.getExportedFileContent] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
@@ -314,17 +435,17 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
result, err := dataExporter.ToExportedContent(c, uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.getExportedFileContent] failed to get exported data for \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
fileName := a.getFileName(user, timezone, fileType)
fileName := a.getFileName(user, clientTimezone, fileType)
return result, fileName, nil
}
func (a *DataManagementsApi) getFileName(user *models.User, timezone *time.Location, fileExtension string) string {
currentTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(time.Now().Unix(), timezone)
func (a *DataManagementsApi) getFileName(user *models.User, clientTimezone *time.Location, fileExtension string) string {
currentTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(time.Now().Unix(), clientTimezone)
currentTime = strings.Replace(currentTime, "-", "_", -1)
currentTime = strings.Replace(currentTime, " ", "_", -1)
currentTime = strings.Replace(currentTime, ":", "_", -1)
+1 -7
View File
@@ -30,13 +30,7 @@ var (
// LatestExchangeRateHandler returns latest exchange rate data
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
dataSource := exchangerates.Container.Current
if dataSource == nil {
return nil, errs.ErrInvalidExchangeRatesDataSource
}
exchangeRateResponse, err := dataSource.GetLatestExchangeRates(c, c.GetCurrentUid(), a.container.Current)
exchangeRateResponse, err := exchangerates.Container.GetLatestExchangeRates(c, c.GetCurrentUid(), a.CurrentConfig())
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
+274
View File
@@ -0,0 +1,274 @@
package api
import (
"encoding/json"
"sort"
"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"
)
// InsightsExplorersApi represents insights explorers api
type InsightsExplorersApi struct {
insightsExploreres *services.InsightsExplorerService
}
// Initialize a insights explorers api singleton instance
var (
InsightsExplorers = &InsightsExplorersApi{
insightsExploreres: services.InsightsExplorers,
}
)
// InsightsExplorerListHandler returns insights explorer list of current user
func (a *InsightsExplorersApi) InsightsExplorerListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
explorers, err := a.insightsExploreres.GetAllInsightsExplorerNamesByUid(c, uid)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerListHandler] failed to get insights explorers for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
explorerResps := make(models.InsightsExplorerInfoResponseSlice, len(explorers))
for i := 0; i < len(explorers); i++ {
explorerResps[i], err = explorers[i].ToInsightsExplorerInfoResponse()
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerListHandler] failed to get insights explorer response for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrInsightsExplorerDataInvalid
}
}
sort.Sort(explorerResps)
return explorerResps, nil
}
// InsightsExplorerGetHandler returns one specific insights explorer of current user
func (a *InsightsExplorersApi) InsightsExplorerGetHandler(c *core.WebContext) (any, *errs.Error) {
var explorerGetReq models.InsightsExplorerGetRequest
err := c.ShouldBindQuery(&explorerGetReq)
if err != nil {
log.Warnf(c, "[explorers.InsightsExplorerGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
explorer, err := a.insightsExploreres.GetInsightsExplorerByExplorerId(c, uid, explorerGetReq.Id)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerGetHandler] failed to get insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
explorerResp, err := explorer.ToInsightsExplorerInfoResponse()
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerGetHandler] failed to get insights explorer response for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrInsightsExplorerDataInvalid
}
return explorerResp, nil
}
// InsightsExplorerCreateHandler saves a new insights explorer by request parameters for current user
func (a *InsightsExplorersApi) InsightsExplorerCreateHandler(c *core.WebContext) (any, *errs.Error) {
var explorerCreateReq models.InsightsExplorerCreateRequest
err := c.ShouldBindJSON(&explorerCreateReq)
if err != nil {
log.Warnf(c, "[explorers.InsightsExplorerCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
maxOrderId, err := a.insightsExploreres.GetMaxDisplayOrder(c, uid)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
explorer, err := a.createNewInsightsExplorerModel(uid, &explorerCreateReq, maxOrderId+1)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerCreateHandler] failed to parse insights explorer data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrInsightsExplorerDataInvalid
}
err = a.insightsExploreres.CreateInsightsExplorer(c, explorer)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerCreateHandler] failed to create insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorer.ExplorerId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[explorers.InsightsExplorerCreateHandler] user \"uid:%d\" has created a new insights explorer \"id:%d\" successfully", uid, explorer.ExplorerId)
explorerResp, err := explorer.ToInsightsExplorerInfoResponse()
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerCreateHandler] failed to get insights explorer response for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrInsightsExplorerDataInvalid
}
return explorerResp, nil
}
// InsightsExplorerModifyHandler saves an existed insights explorer by request parameters for current user
func (a *InsightsExplorersApi) InsightsExplorerModifyHandler(c *core.WebContext) (any, *errs.Error) {
var explorerModifyReq models.InsightsExplorerModifyRequest
err := c.ShouldBindJSON(&explorerModifyReq)
if err != nil {
log.Warnf(c, "[explorers.InsightsExplorerModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
explorer, err := a.insightsExploreres.GetInsightsExplorerByExplorerId(c, uid, explorerModifyReq.Id)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerModifyHandler] failed to get insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newData, err := json.Marshal(explorerModifyReq.Data)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerModifyHandler] failed to parse insights explorer data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrInsightsExplorerDataInvalid
}
newExplorer := &models.InsightsExplorer{
ExplorerId: explorer.ExplorerId,
Uid: uid,
Name: explorerModifyReq.Name,
Data: string(newData),
}
if newExplorer.Name == explorer.Name && newExplorer.Data == explorer.Data {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.insightsExploreres.ModifyInsightsExplorer(c, newExplorer)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerModifyHandler] failed to update insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[explorers.InsightsExplorerModifyHandler] user \"uid:%d\" has updated insights explorer \"id:%d\" successfully", uid, explorerModifyReq.Id)
explorer.Name = newExplorer.Name
explorer.Data = newExplorer.Data
explorerResp, err := explorer.ToInsightsExplorerInfoResponse()
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerModifyHandler] failed to get insights explorer response for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrInsightsExplorerDataInvalid
}
return explorerResp, nil
}
// InsightsExplorerHideHandler hides a insights explorer by request parameters for current user
func (a *InsightsExplorersApi) InsightsExplorerHideHandler(c *core.WebContext) (any, *errs.Error) {
var explorerHideReq models.InsightsExplorerHideRequest
err := c.ShouldBindJSON(&explorerHideReq)
if err != nil {
log.Warnf(c, "[explorers.InsightsExplorerHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.insightsExploreres.HideInsightsExplorer(c, uid, []int64{explorerHideReq.Id}, explorerHideReq.Hidden)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerHideHandler] failed to hide insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[explorers.InsightsExplorerHideHandler] user \"uid:%d\" has hidden insights explorer \"id:%d\"", uid, explorerHideReq.Id)
return true, nil
}
// InsightsExplorerMoveHandler moves display order of existed insights explorers by request parameters for current user
func (a *InsightsExplorersApi) InsightsExplorerMoveHandler(c *core.WebContext) (any, *errs.Error) {
var explorerMoveReq models.InsightsExplorerMoveRequest
err := c.ShouldBindJSON(&explorerMoveReq)
if err != nil {
log.Warnf(c, "[explorers.InsightsExplorerMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
explorers := make([]*models.InsightsExplorer, len(explorerMoveReq.NewDisplayOrders))
for i := 0; i < len(explorerMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := explorerMoveReq.NewDisplayOrders[i]
explorer := &models.InsightsExplorer{
Uid: uid,
ExplorerId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
explorers[i] = explorer
}
err = a.insightsExploreres.ModifyInsightsExplorerDisplayOrders(c, uid, explorers)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerMoveHandler] failed to move insights explorers for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[explorers.InsightsExplorerMoveHandler] user \"uid:%d\" has moved insights explorers", uid)
return true, nil
}
// InsightsExplorerDeleteHandler deletes an existed insights explorer by request parameters for current user
func (a *InsightsExplorersApi) InsightsExplorerDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var explorerDeleteReq models.InsightsExplorerDeleteRequest
err := c.ShouldBindJSON(&explorerDeleteReq)
if err != nil {
log.Warnf(c, "[explorers.InsightsExplorerDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.insightsExploreres.DeleteInsightsExplorer(c, uid, explorerDeleteReq.Id)
if err != nil {
log.Errorf(c, "[explorers.InsightsExplorerDeleteHandler] failed to delete insights explorer \"id:%d\" for user \"uid:%d\", because %s", explorerDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[explorers.InsightsExplorerDeleteHandler] user \"uid:%d\" has deleted insights explorer \"id:%d\"", uid, explorerDeleteReq.Id)
return true, nil
}
func (a *InsightsExplorersApi) createNewInsightsExplorerModel(uid int64, explorerCreateReq *models.InsightsExplorerCreateRequest, order int32) (*models.InsightsExplorer, error) {
data, err := json.Marshal(explorerCreateReq.Data)
if err != nil {
return nil, err
}
return &models.InsightsExplorer{
Uid: uid,
Name: explorerCreateReq.Name,
Data: string(data),
DisplayOrder: order,
}, nil
}
+23 -1
View File
@@ -4,6 +4,7 @@ import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -14,6 +15,7 @@ import (
// ForgetPasswordsApi represents user forget password api
type ForgetPasswordsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
users *services.UserService
tokens *services.TokenService
forgetPasswords *services.ForgetPasswordService
@@ -25,6 +27,12 @@ var (
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
users: services.Users,
tokens: services.Tokens,
forgetPasswords: services.ForgetPasswords,
@@ -41,6 +49,13 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext
return nil, errs.ErrEmailIsEmptyOrInvalid
}
err = a.CheckFailureCount(c, 0)
if err != nil {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send forget password mail to \"%s\", because %s", request.Email, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
user, err := a.users.GetUserByEmail(c, request.Email)
if err != nil {
@@ -48,6 +63,13 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error())
}
failureCheckErr := a.CheckAndIncreaseFailureCount(c, 0)
if failureCheckErr != nil {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send forget password mail to \"%s\", because %s", request.Email, failureCheckErr.Error())
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
}
return nil, errs.ErrUserNotFound
}
@@ -124,7 +146,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any,
if user.Email != request.Email {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
return nil, errs.ErrEmptyIsInvalid
return nil, errs.ErrEmailIsInvalid
}
if a.users.IsPasswordEqualsUserPassword(request.Password, user) {
+376
View File
@@ -0,0 +1,376 @@
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
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.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 {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get system prompt template for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
systemPromptParams := map[string]any{
"CurrentDateTime": utils.FormatUnixTimeToLongDateTime(time.Now().Unix(), clientTimezone),
"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 {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get final system prompt from template for user \"uid:%d\", because %s", uid, err.Error())
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 {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get llm response user \"uid:%d\", because %s", uid, err.Error())
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, clientTimezone, result, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (a *LargeLanguageModelsApi) parseRecognizedReceiptImageResponse(c *core.WebContext, uid int64, clientTimezone *time.Location, 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.ParseFromLongDateTimeInTimeZone(longDateTime, clientTimezone)
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
}
+20 -4
View File
@@ -5,11 +5,12 @@ import (
"net/http/httputil"
"net/url"
"strings"
"sync"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const openStreetMapTileImageUrlFormat = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" // https://tile.openstreetmap.org/{z}/{x}/{y}.png
@@ -25,6 +26,8 @@ const tianDiTuMapAnnotationUrlFormat = "https://t0.tianditu.gov.cn/cva_w/wmts?SE
// MapImageProxy represents map image proxy
type MapImageProxy struct {
ApiUsingConfig
mutex sync.Mutex
transport *http.Transport
}
// Initialize a map image proxy singleton instance
@@ -36,6 +39,18 @@ var (
}
)
func (p *MapImageProxy) initializeHttpTransport() {
p.mutex.Lock()
defer p.mutex.Unlock()
if p.transport != nil {
return
}
p.transport = http.DefaultTransport.(*http.Transport).Clone()
httpclient.SetProxyUrl(p.transport, p.CurrentConfig().MapProxy)
}
// MapTileImageProxyHandler returns map tile image
func (p *MapImageProxy) MapTileImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
return p.mapImageProxyHandler(c, func(c *core.WebContext, mapProvider string) (string, *errs.Error) {
@@ -109,8 +124,9 @@ func (p *MapImageProxy) mapImageProxyHandler(c *core.WebContext, fn func(c *core
return nil, err
}
transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, p.CurrentConfig().MapProxy)
if p.transport == nil {
p.initializeHttpTransport()
}
director := func(req *http.Request) {
imageRawUrl := targetUrl
@@ -126,7 +142,7 @@ func (p *MapImageProxy) mapImageProxyHandler(c *core.WebContext, fn func(c *core
}
return &httputil.ReverseProxy{
Transport: transport,
Transport: p.transport,
Director: director,
}, nil
}
+3 -5
View File
@@ -3,8 +3,6 @@ package api
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
@@ -15,7 +13,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const mcpServerName = "ezBookkeeping-mcp"
const mcpServerName = core.ApplicationName + "-mcp"
// ModelContextProtocolAPI represents model context protocol api
type ModelContextProtocolAPI struct {
@@ -104,7 +102,7 @@ func (a *ModelContextProtocolAPI) InitializeHandler(c *core.WebContext, jsonRPCR
},
ServerInfo: &mcp.MCPImplementation{
Name: mcpServerName,
Title: a.CurrentConfig().AppName,
Title: core.ApplicationName,
Version: settings.Version,
},
}
@@ -233,7 +231,7 @@ func (a *ModelContextProtocolAPI) CallToolHandler(c *core.WebContext, jsonRPCReq
// PingHandler return the ping response for model context protocol
func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
return gin.H{}, nil
return core.O{}, nil
}
// GetTransactionService implements the MCPAvailableServices interface
+423
View File
@@ -0,0 +1,423 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/locales"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
const oauth2CallbackPageUrlSuccessFormat = "%sdesktop#/oauth2_callback?platform=%s&provider=%s&token=%s"
const oauth2CallbackPageUrlNeedVerifyFormat = "%sdesktop#/oauth2_callback?platform=%s&provider=%s&userName=%s&token=%s"
const oauth2CallbackPageUrlFailedFormat = "%sdesktop#/oauth2_callback?errorCode=%d&errorMessage=%s"
const oauth2CallbackPageUrlErrorMessageFormat = "%sdesktop#/oauth2_callback?errorMessage=%s"
// OAuth2AuthenticationApi represents OAuth 2.0 authorization api
type OAuth2AuthenticationApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
users *services.UserService
tokens *services.TokenService
userExternalAuths *services.UserExternalAuthService
}
// Initialize a OAuth 2.0 authentication api singleton instance
var (
OAuth2Authentications = &OAuth2AuthenticationApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
users: services.Users,
tokens: services.Tokens,
userExternalAuths: services.UserExternalAuths,
}
)
// LoginHandler handles user login request via OAuth 2.0
func (a *OAuth2AuthenticationApi) LoginHandler(c *core.WebContext) (string, *errs.Error) {
var oauth2LoginReq models.OAuth2LoginRequest
err := c.ShouldBindQuery(&oauth2LoginReq)
if err != nil {
log.Warnf(c, "[oauth2_authentications.LoginHandler] parse request failed, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.NewIncompleteOrIncorrectSubmissionError(err))
}
if oauth2LoginReq.Platform != "mobile" && oauth2LoginReq.Platform != "desktop" {
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2LoginRequest)
}
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId)
if found {
log.Errorf(c, "[oauth2_authentications.LoginHandler] another oauth 2.0 state \"%s\" has been processing for client session id \"%s\"", remark, oauth2LoginReq.ClientSessionId)
return a.redirectToFailedCallbackPage(c, errs.ErrRepeatedRequest)
}
uid := int64(0)
if oauth2LoginReq.Token != "" {
_, claims, _, err := a.tokens.ParseToken(c, oauth2LoginReq.Token)
if err != nil {
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to parse token, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidToken)
}
uid = claims.Uid
user, err := a.users.GetUserById(c, uid)
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to get user by id %d, because %s", uid, err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
}
}
verifier, err := utils.GetRandomNumberOrLowercaseLetter(64)
if err != nil {
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to generate random string for oauth 2.0 state, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.ErrSystemError)
}
remark = fmt.Sprintf("%s|%s|%d|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, uid, verifier)
state := fmt.Sprintf("%s|%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, utils.MD5EncodeToString([]byte(remark)))
redirectUrl, err := oauth2.GetOAuth2AuthUrl(c, state, verifier)
if err != nil {
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to get oauth 2.0 auth url, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrSystemError))
}
a.SetSubmissionRemarkWithCustomExpiration(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId, remark, a.CurrentConfig().OAuth2StateExpiredTimeDuration)
return redirectUrl, nil
}
// CallbackHandler handles OAuth 2.0 callback request
func (a *OAuth2AuthenticationApi) CallbackHandler(c *core.WebContext) (string, *errs.Error) {
var oauth2CallbackReq models.OAuth2CallbackRequest
err := c.ShouldBindQuery(&oauth2CallbackReq)
if err != nil {
log.Warnf(c, "[oauth2_authentications.CallbackHandler] parse request failed, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.NewIncompleteOrIncorrectSubmissionError(err))
}
if oauth2CallbackReq.State == "" {
return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2State)
}
if oauth2CallbackReq.Code == "" {
if oauth2CallbackReq.ErrorDescription != "" {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] oauth 2.0 provider returned error: %s, description: %s", oauth2CallbackReq.Error, oauth2CallbackReq.ErrorDescription)
return a.redirectToErrorMessageCallbackPage(c, oauth2CallbackReq.ErrorDescription)
}
return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2Code)
}
platform := ""
clientSessionId := ""
stateParts := strings.Split(oauth2CallbackReq.State, "|")
if len(stateParts) == 3 {
platform = stateParts[0]
clientSessionId = stateParts[1]
} else {
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
}
if platform != "mobile" && platform != "desktop" {
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2LoginRequest)
}
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId)
if !found {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] cannot find oauth 2.0 state in duplicate checker for client session id \"%s\"", clientSessionId)
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2Callback)
}
remarkParts := strings.Split(remark, "|")
if len(remarkParts) != 4 || remarkParts[0] != platform || remarkParts[1] != clientSessionId {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 state \"%s\" in duplicate checker for client session id \"%s\"", remark, clientSessionId)
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
}
uid, err := utils.StringToInt64(remarkParts[2])
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid uid \"%s\" in oauth 2.0 state \"%s\"", remarkParts[2], remark)
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
}
verifier := remarkParts[3]
expectedRemark := fmt.Sprintf("%s|%s|%d|%s", platform, clientSessionId, uid, verifier)
expectedState := fmt.Sprintf("%s|%s|%s", platform, clientSessionId, utils.MD5EncodeToString([]byte(expectedRemark)))
if oauth2CallbackReq.State != expectedState {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] mismatched random string in oauth 2.0 state, expected \"%s\", got \"%s\"", expectedState, oauth2CallbackReq.State)
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
}
a.RemoveSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId)
oauth2Token, err := oauth2.GetOAuth2Token(c, oauth2CallbackReq.Code, verifier)
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 token, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrCannotRetrieveOAuth2Token))
}
oauth2UserInfo, err := oauth2.GetOAuth2UserInfo(c, oauth2Token)
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrInvalidOAuth2Token))
}
if oauth2UserInfo == nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because user info is nil")
return a.redirectToFailedCallbackPage(c, errs.ErrCannotRetrieveUserInfo)
}
log.Infof(c, "[oauth2_authentications.CallbackHandler] oauth 2.0 user info, userName: %s, email: %s", oauth2UserInfo.UserName, oauth2UserInfo.Email)
if oauth2UserInfo.UserName == "" && oauth2UserInfo.Email == "" {
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserNameAndEmailEmpty)
}
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail && oauth2UserInfo.Email == "" {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 user info, email is empty")
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2EmailEmpty)
}
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername && oauth2UserInfo.UserName == "" {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 user info, userName is empty")
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserNameEmpty)
}
userExternalAuthType := oauth2.GetExternalUserAuthType()
var userExternalAuth *models.UserExternalAuth
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalEmail(c, oauth2UserInfo.Email, userExternalAuthType)
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalUserName(c, oauth2UserInfo.UserName, userExternalAuthType)
} else {
return a.redirectToFailedCallbackPage(c, errs.ErrNotSupported)
}
if err != nil && !errors.Is(err, errs.ErrUserExternalAuthNotFound) {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user external auth, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
}
if uid != 0 && userExternalAuth != nil && userExternalAuth.Uid != uid {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] oauth 2.0 external auth has been bound to another user \"uid:%d\", current user \"uid:%d\"", userExternalAuth.Uid, uid)
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserAlreadyBoundToAnotherUser)
}
var user *models.User
if err == nil { // user already bound to external auth, redirect to success page
user, err = a.users.GetUserById(c, userExternalAuth.Uid)
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user by id %d, because %s", userExternalAuth.Uid, err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
}
} else { // errors.Is(err, errs.ErrUserExternalAuthNotFound) // user not bound to external auth, try to bind or register new user
if uid != 0 {
user, err = a.users.GetUserById(c, uid)
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user by id %d, because %s", uid, err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
}
} else {
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
user, err = a.users.GetUserByEmail(c, oauth2UserInfo.Email)
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
user, err = a.users.GetUserByUsername(c, oauth2UserInfo.UserName)
} else {
err = errs.ErrNotSupported
}
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
}
}
if user == nil && a.CurrentConfig().EnableUserRegister && a.CurrentConfig().OAuth2AutoRegister {
if oauth2UserInfo.UserName == "" {
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserNameEmptyCannotRegister)
}
if oauth2UserInfo.Email == "" {
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2EmailEmptyCannotRegister)
}
userName := strings.TrimSpace(oauth2UserInfo.UserName)
email := strings.TrimSpace(oauth2UserInfo.Email)
nickName := strings.TrimSpace(oauth2UserInfo.NickName)
languageCode := ""
currencyCode := "USD"
if nickName == "" {
nickName = userName
}
if !utils.IsValidUsername(userName) {
return a.redirectToFailedCallbackPage(c, errs.ErrUserNameIsInvalid)
}
if !utils.IsValidEmail(email) {
return a.redirectToFailedCallbackPage(c, errs.ErrEmailIsInvalid)
}
if !utils.IsValidNickName(nickName) {
return a.redirectToFailedCallbackPage(c, errs.ErrNickNameIsInvalid)
}
if _, exists := locales.AllLanguages[oauth2UserInfo.LanguageCode]; exists {
languageCode = oauth2UserInfo.LanguageCode
}
if _, exists := validators.AllCurrencyNames[oauth2UserInfo.CurrencyCode]; exists {
currencyCode = oauth2UserInfo.CurrencyCode
}
user = &models.User{
Username: userName,
Email: email,
Nickname: nickName,
Language: languageCode,
DefaultCurrency: currencyCode,
FirstDayOfWeek: oauth2UserInfo.FirstDayOfWeek,
FiscalYearStart: core.FISCAL_YEAR_START_DEFAULT,
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
}
err = a.users.CreateUser(c, user, true)
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
}
log.Infof(c, "[oauth2_authentications.CallbackHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
userExternalAuth = &models.UserExternalAuth{
Uid: user.Uid,
ExternalAuthType: userExternalAuthType,
ExternalUsername: oauth2UserInfo.UserName,
ExternalEmail: oauth2UserInfo.Email,
}
err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth)
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error())
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
}
log.Infof(c, "[oauth2_authentications.CallbackHandler] user external auth has been created for user \"uid:%d\"", user.Uid)
} else if user == nil {
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2AutoRegistrationNotEnabled)
}
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
}
if userExternalAuth == nil {
tokenContext, err := json.Marshal(&models.OAuth2CallbackTokenContext{
ExternalAuthType: userExternalAuthType,
ExternalUsername: oauth2UserInfo.UserName,
ExternalEmail: oauth2UserInfo.Email,
})
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to marshal oauth 2.0 callback verify token context, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.ErrOperationFailed)
}
token, _, err := a.tokens.CreateOAuth2CallbackRequireVerifyToken(c, user, string(tokenContext))
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback verify token, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating)
}
return a.redirectToVerifyCallbackPage(c, platform, userExternalAuthType, user.Username, token)
} else {
tokenContext, err := json.Marshal(&models.OAuth2CallbackTokenContext{
ExternalAuthType: userExternalAuthType,
})
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to marshal oauth 2.0 callback token context, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.ErrOperationFailed)
}
token, _, err := a.tokens.CreateOAuth2CallbackToken(c, user, string(tokenContext))
if err != nil {
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback token, because %s", err.Error())
return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating)
}
return a.redirectToSuccessCallbackPage(c, platform, userExternalAuthType, token)
}
}
func (a *OAuth2AuthenticationApi) redirectToSuccessCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, token string) (string, *errs.Error) {
return fmt.Sprintf(oauth2CallbackPageUrlSuccessFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, url.QueryEscape(token)), nil
}
func (a *OAuth2AuthenticationApi) redirectToVerifyCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, userName string, token string) (string, *errs.Error) {
return fmt.Sprintf(oauth2CallbackPageUrlNeedVerifyFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, userName, url.QueryEscape(token)), nil
}
func (a *OAuth2AuthenticationApi) redirectToFailedCallbackPage(c *core.WebContext, err *errs.Error) (string, *errs.Error) {
return fmt.Sprintf(oauth2CallbackPageUrlFailedFormat, a.CurrentConfig().RootUrl, err.Code(), url.QueryEscape(utils.GetDisplayErrorMessage(err))), nil
}
func (a *OAuth2AuthenticationApi) redirectToErrorMessageCallbackPage(c *core.WebContext, message string) (string, *errs.Error) {
return fmt.Sprintf(oauth2CallbackPageUrlErrorMessageFormat, a.CurrentConfig().RootUrl, url.QueryEscape(message)), nil
}
+18 -3
View File
@@ -35,18 +35,33 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext)
builder := &strings.Builder{}
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
a.appendBooleanSetting(builder, "r", config.EnableUserRegister)
a.appendBooleanSetting(builder, "f", config.EnableUserForgetPassword)
a.appendBooleanSetting(builder, "a", config.EnableInternalAuth)
a.appendBooleanSetting(builder, "o", config.EnableOAuth2Login)
a.appendBooleanSetting(builder, "r", config.EnableInternalAuth && config.EnableUserRegister)
a.appendBooleanSetting(builder, "f", config.EnableInternalAuth && config.EnableUserForgetPassword)
a.appendBooleanSetting(builder, "t", config.EnableAPIToken)
a.appendBooleanSetting(builder, "v", config.EnableUserVerifyEmail)
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
a.appendStringSetting(builder, "op", config.OAuth2Provider)
if config.OAuth2Provider == settings.OAuth2ProviderOIDC && config.OAuth2OIDCCustomDisplayNameConfig.Enabled {
a.appendMultiLanguageTipSetting(builder, "ocn", config.OAuth2OIDCCustomDisplayNameConfig)
}
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)
}
@@ -132,7 +147,7 @@ func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key st
builder.WriteString(";\n")
}
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.TipConfig) {
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.MultiLanguageContentConfig) {
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
builder.WriteString("[")
a.appendEncodedString(builder, key)
+59 -3
View File
@@ -69,7 +69,9 @@ 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 {
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != services.TokenUserAgentCreatedViaCli {
tokenResp.UserAgent = services.TokenUserAgentForAPI
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != services.TokenUserAgentCreatedViaCli {
tokenResp.UserAgent = services.TokenUserAgentForMCP
}
@@ -81,6 +83,53 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
return tokenResps, nil
}
// TokenGenerateAPIHandler generates a new API token for current user
func (a *TokensApi) TokenGenerateAPIHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableAPIToken {
return nil, errs.ErrAPITokenNotEnabled
}
var generateAPITokenReq models.TokenGenerateAPIRequest
err := c.ShouldBindJSON(&generateAPITokenReq)
if err != nil {
log.Warnf(c, "[tokens.TokenGenerateAPIHandler] 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.TokenGenerateAPIHandler] 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_GENERATE_API_TOKEN) {
return false, errs.ErrNotPermittedToPerformThisAction
}
if !a.users.IsPasswordEqualsUserPassword(generateAPITokenReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
token, claims, err := a.tokens.CreateAPIToken(c, user, generateAPITokenReq.ExpiredInSeconds)
if err != nil {
log.Errorf(c, "[tokens.TokenGenerateAPIHandler] failed to create api token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrTokenGenerating)
}
log.Infof(c, "[tokens.TokenGenerateAPIHandler] user \"uid:%d\" has generated api token, new token will be expired at %d", user.Uid, claims.ExpiresAt)
generateAPITokenResp := &models.TokenGenerateAPIResponse{
Token: token,
APIBaseUrl: a.CurrentConfig().RootUrl + "api",
}
return generateAPITokenResp, nil
}
// TokenGenerateMCPHandler generates a new MCP token for current user
func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableMCPServer {
@@ -111,7 +160,7 @@ func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Erro
return nil, errs.ErrUserPasswordWrong
}
token, claims, err := a.tokens.CreateMCPToken(c, user)
token, claims, err := a.tokens.CreateMCPToken(c, user, generateMCPTokenReq.ExpiredInSeconds)
if err != nil {
log.Errorf(c, "[tokens.TokenGenerateMCPHandler] failed to create mcp token for user \"uid:%d\", because %s", user.Uid, err.Error())
@@ -130,7 +179,13 @@ func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Erro
// 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))
@@ -338,6 +393,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
c.SetTextualToken(token)
c.SetTokenClaims(claims)
c.SetTokenContext("")
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
+10 -1
View File
@@ -214,6 +214,7 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (an
Uid: uid,
ParentCategoryId: categoryModifyReq.ParentId,
Name: categoryModifyReq.Name,
DisplayOrder: category.DisplayOrder,
Icon: categoryModifyReq.Icon,
Color: categoryModifyReq.Color,
Comment: categoryModifyReq.Comment,
@@ -259,6 +260,15 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (an
if toPrimaryCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
return nil, errs.Or(err, errs.ErrNotAllowUseSecondaryTransactionAsPrimaryCategory)
}
maxOrderId, err := a.categories.GetMaxSubCategoryDisplayOrder(c, uid, category.Type, newCategory.ParentCategoryId)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newCategory.DisplayOrder = maxOrderId + 1
}
err = a.categories.ModifyCategory(c, newCategory)
@@ -271,7 +281,6 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (an
log.Infof(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
newCategory.Type = category.Type
newCategory.DisplayOrder = category.DisplayOrder
categoryResp := newCategory.ToTransactionCategoryInfoResponse()
return categoryResp, nil
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"sort"
"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"
)
// TransactionTagGroupsApi represents transaction tag group api
type TransactionTagGroupsApi struct {
tagGroups *services.TransactionTagGroupService
}
// Initialize a transaction tag group api singleton instance
var (
TransactionTagGroups = &TransactionTagGroupsApi{
tagGroups: services.TransactionTagGroups,
}
)
// TagGroupListHandler returns transaction tag group list of current user
func (a *TransactionTagGroupsApi) TagGroupListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tagGroups, err := a.tagGroups.GetAllTagGroupsByUid(c, uid)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupListHandler] failed to get tag groups for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagGroupResps := make(models.TransactionTagGroupInfoResponseSlice, len(tagGroups))
for i := 0; i < len(tagGroups); i++ {
tagGroupResps[i] = tagGroups[i].ToTransactionTagGroupInfoResponse()
}
sort.Sort(tagGroupResps)
return tagGroupResps, nil
}
// TagGroupGetHandler returns one specific transaction tag group of current user
func (a *TransactionTagGroupsApi) TagGroupGetHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupGetReq models.TransactionTagGroupGetRequest
err := c.ShouldBindQuery(&tagGroupGetReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagGroupGetReq.Id)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupGetHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
return tagGroupResp, nil
}
// TagGroupCreateHandler saves a new transaction tag group by request parameters for current user
func (a *TransactionTagGroupsApi) TagGroupCreateHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupCreateReq models.TransactionTagGroupCreateRequest
err := c.ShouldBindJSON(&tagGroupCreateReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
maxOrderId, err := a.tagGroups.GetMaxDisplayOrder(c, uid)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagGroup := a.createNewTagGroupModel(uid, &tagGroupCreateReq, maxOrderId+1)
err = a.tagGroups.CreateTagGroup(c, tagGroup)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupCreateHandler] failed to create tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroup.TagGroupId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tag_groups.TagGroupCreateHandler] user \"uid:%d\" has created a new tag group \"id:%d\" successfully", uid, tagGroup.TagGroupId)
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
return tagGroupResp, nil
}
// TagGroupModifyHandler saves an existed transaction tag group by request parameters for current user
func (a *TransactionTagGroupsApi) TagGroupModifyHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupModifyReq models.TransactionTagGroupModifyRequest
err := c.ShouldBindJSON(&tagGroupModifyReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagGroupModifyReq.Id)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newTagGroup := &models.TransactionTagGroup{
TagGroupId: tagGroup.TagGroupId,
Uid: uid,
Name: tagGroupModifyReq.Name,
}
if newTagGroup.Name == tagGroup.Name {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.tagGroups.ModifyTagGroup(c, newTagGroup)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupModifyHandler] failed to update tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tag_groups.TagGroupModifyHandler] user \"uid:%d\" has updated tag group \"id:%d\" successfully", uid, tagGroupModifyReq.Id)
tagGroup.Name = newTagGroup.Name
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
return tagGroupResp, nil
}
// TagGroupMoveHandler moves display order of existed transaction tag groups by request parameters for current user
func (a *TransactionTagGroupsApi) TagGroupMoveHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupMoveReq models.TransactionTagGroupMoveRequest
err := c.ShouldBindJSON(&tagGroupMoveReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tagGroups := make([]*models.TransactionTagGroup, len(tagGroupMoveReq.NewDisplayOrders))
for i := 0; i < len(tagGroupMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := tagGroupMoveReq.NewDisplayOrders[i]
tagGroup := &models.TransactionTagGroup{
Uid: uid,
TagGroupId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
tagGroups[i] = tagGroup
}
err = a.tagGroups.ModifyTagGroupDisplayOrders(c, uid, tagGroups)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupMoveHandler] failed to move tag groups for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tag_groups.TagGroupMoveHandler] user \"uid:%d\" has moved tag groups", uid)
return true, nil
}
// TagGroupDeleteHandler deletes an existed transaction tag group by request parameters for current user
func (a *TransactionTagGroupsApi) TagGroupDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupDeleteReq models.TransactionTagGroupDeleteRequest
err := c.ShouldBindJSON(&tagGroupDeleteReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.tagGroups.DeleteTagGroup(c, uid, tagGroupDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupDeleteHandler] failed to delete tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tag_groups.TagGroupDeleteHandler] user \"uid:%d\" has deleted tag group \"id:%d\"", uid, tagGroupDeleteReq.Id)
return true, nil
}
func (a *TransactionTagGroupsApi) createNewTagGroupModel(uid int64, tagGroupCreateReq *models.TransactionTagGroupCreateRequest, order int32) *models.TransactionTagGroup {
return &models.TransactionTagGroup{
Uid: uid,
Name: tagGroupCreateReq.Name,
DisplayOrder: order,
}
}
+33 -7
View File
@@ -78,7 +78,7 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er
uid := c.GetCurrentUid()
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId)
if err != nil {
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
@@ -111,9 +111,16 @@ func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *er
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
if tagCreateBatchReq.Tags[i].GroupId != tagCreateBatchReq.GroupId {
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the group id \"%d\" of tag#%d is inconsistent with the batch group id \"%d\"", tagCreateBatchReq.Tags[i].GroupId, i, tagCreateBatchReq.GroupId)
return nil, errs.ErrTransactionTagGroupIdInvalid
}
}
uid := c.GetCurrentUid()
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateBatchReq.GroupId)
if err != nil {
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
@@ -161,16 +168,31 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er
}
newTag := &models.TransactionTag{
TagId: tag.TagId,
Uid: uid,
Name: tagModifyReq.Name,
TagId: tag.TagId,
Uid: uid,
Name: tagModifyReq.Name,
TagGroupId: tagModifyReq.GroupId,
DisplayOrder: tag.DisplayOrder,
}
if newTag.Name == tag.Name {
tagNameChanged := newTag.Name != tag.Name
if !tagNameChanged && newTag.TagGroupId == tag.TagGroupId {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.tags.ModifyTag(c, newTag)
if newTag.TagGroupId != tag.TagGroupId {
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, newTag.TagGroupId)
if err != nil {
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newTag.DisplayOrder = maxOrderId + 1
}
err = a.tags.ModifyTag(c, newTag, tagNameChanged)
if err != nil {
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
@@ -180,6 +202,8 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er
log.Infof(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
tag.Name = newTag.Name
tag.TagGroupId = newTag.TagGroupId
tag.DisplayOrder = newTag.DisplayOrder
tagResp := tag.ToTransactionTagInfoResponse()
return tagResp, nil
@@ -268,6 +292,7 @@ func (a *TransactionTagsApi) createNewTagModel(uid int64, tagCreateReq *models.T
return &models.TransactionTag{
Uid: uid,
Name: tagCreateReq.Name,
TagGroupId: tagCreateReq.GroupId,
DisplayOrder: order,
}
}
@@ -278,6 +303,7 @@ func (a *TransactionTagsApi) createNewTagModels(uid int64, tagCreateBatchReq *mo
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
tagCreateReq := tagCreateBatchReq.Tags[i]
tag := a.createNewTagModel(uid, tagCreateReq, order+int32(i))
tag.TagGroupId = tagCreateBatchReq.GroupId
tags[i] = tag
}
+440 -57
View File
@@ -4,8 +4,10 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"sort"
"strings"
"time"
orderedmap "github.com/wk8/go-ordered-map/v2"
@@ -22,6 +24,8 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const pageCountForAccountStatement = 1000
// TransactionsApi represents transaction api
type TransactionsApi struct {
ApiUsingConfig
@@ -81,19 +85,19 @@ func (a *TransactionsApi) TransactionCountHandler(c *core.WebContext) (any, *err
return nil, errs.Or(err, errs.ErrOperationFailed)
}
var allTagIds []int64
noTags := transactionCountReq.TagIds == "none"
noTags := transactionCountReq.TagFilter == models.TransactionNoTagFilterValue
var tagFilters []*models.TransactionTagFilter
if !noTags {
allTagIds, err = a.transactionTags.GetTagIds(transactionCountReq.TagIds)
tagFilters, err = models.ParseTransactionTagFilter(transactionCountReq.TagFilter)
if err != nil {
log.Warnf(c, "[transactions.TransactionCountHandler] get transaction tag ids error, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionCountHandler] parse transaction filters error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
}
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionCountReq.TagFilterType, transactionCountReq.AmountFilter, transactionCountReq.Keyword)
totalCount, err := a.transactions.GetTransactionCount(c, uid, transactionCountReq.MaxTime, transactionCountReq.MinTime, transactionCountReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionCountReq.AmountFilter, transactionCountReq.Keyword)
if err != nil {
log.Errorf(c, "[transactions.TransactionCountHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
@@ -117,10 +121,10 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionListHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionListHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -149,14 +153,14 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
return nil, errs.Or(err, errs.ErrOperationFailed)
}
var allTagIds []int64
noTags := transactionListReq.TagIds == "none"
noTags := transactionListReq.TagFilter == models.TransactionNoTagFilterValue
var tagFilters []*models.TransactionTagFilter
if !noTags {
allTagIds, err = a.transactionTags.GetTagIds(transactionListReq.TagIds)
tagFilters, err = models.ParseTransactionTagFilter(transactionListReq.TagFilter)
if err != nil {
log.Warnf(c, "[transactions.TransactionListHandler] get transaction tag ids error, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionListHandler] parse transaction tag filters error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
}
@@ -164,7 +168,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
var totalCount int64
if transactionListReq.WithCount {
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword)
totalCount, err = a.transactions.GetTransactionCount(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
if err != nil {
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
@@ -172,7 +176,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
}
}
transactions, err := a.transactions.GetTransactionsByMaxTime(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.Page, transactionListReq.Count, true, true)
transactions, err := a.transactions.GetTransactionsByMaxTime(c, uid, transactionListReq.MaxTime, transactionListReq.MinTime, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword, transactionListReq.Page, transactionListReq.Count, true, true)
if err != nil {
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transactions earlier than \"%d\" for user \"uid:%d\", because %s", transactionListReq.MaxTime, uid, err.Error())
@@ -188,7 +192,7 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
transactions = transactions[:transactionListReq.Count]
}
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, utcOffset, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
if err != nil {
log.Errorf(c, "[transactions.TransactionListHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
@@ -220,10 +224,10 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionMonthListHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionMonthListHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -252,26 +256,26 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
return nil, errs.Or(err, errs.ErrOperationFailed)
}
var allTagIds []int64
noTags := transactionListReq.TagIds == "none"
noTags := transactionListReq.TagFilter == models.TransactionNoTagFilterValue
var tagFilters []*models.TransactionTagFilter
if !noTags {
allTagIds, err = a.transactionTags.GetTagIds(transactionListReq.TagIds)
tagFilters, err = models.ParseTransactionTagFilter(transactionListReq.TagFilter)
if err != nil {
log.Warnf(c, "[transactions.TransactionMonthListHandler] get transaction tag ids error, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionMonthListHandler] parse transaction tag filters error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
}
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, allTagIds, noTags, transactionListReq.TagFilterType, transactionListReq.AmountFilter, transactionListReq.Keyword)
transactions, err := a.transactions.GetTransactionsInMonthByPage(c, uid, transactionListReq.Year, transactionListReq.Month, transactionListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionListReq.AmountFilter, transactionListReq.Keyword)
if err != nil {
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to get transactions in month \"%d-%d\" for user \"uid:%d\", because %s", transactionListReq.Year, transactionListReq.Month, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, utcOffset, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
if err != nil {
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
@@ -286,6 +290,196 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
return transactionResps, nil
}
// TransactionListAllHandler returns all transaction list of current user
func (a *TransactionsApi) TransactionListAllHandler(c *core.WebContext) (any, *errs.Error) {
var transactionAllListReq models.TransactionAllListRequest
err := c.ShouldBindQuery(&transactionAllListReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionListAllHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionListAllHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
allAccountIds, err := a.accounts.GetAccountOrSubAccountIds(c, transactionAllListReq.AccountIds, uid)
if err != nil {
log.Warnf(c, "[transactions.TransactionListAllHandler] get account error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allCategoryIds, err := a.transactionCategories.GetCategoryOrSubCategoryIds(c, transactionAllListReq.CategoryIds, uid)
if err != nil {
log.Warnf(c, "[transactions.TransactionListAllHandler] get transaction category error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
noTags := transactionAllListReq.TagFilter == models.TransactionNoTagFilterValue
var tagFilters []*models.TransactionTagFilter
if !noTags {
tagFilters, err = models.ParseTransactionTagFilter(transactionAllListReq.TagFilter)
if err != nil {
log.Warnf(c, "[transactions.TransactionListAllHandler] parse transaction tag filters error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
}
maxTransactionTime := int64(math.MaxInt64)
minTransactionTime := int64(0)
if transactionAllListReq.EndTime > 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(transactionAllListReq.EndTime)
}
if transactionAllListReq.StartTime > 0 {
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(transactionAllListReq.StartTime)
}
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, transactionAllListReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, transactionAllListReq.AmountFilter, transactionAllListReq.Keyword, pageCountForDataExport, true)
if err != nil {
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to get all transactions for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
transactionResult, err := a.getTransactionResponseListResult(c, user, allTransactions, clientTimezone, transactionAllListReq.WithPictures, transactionAllListReq.TrimAccount, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
if err != nil {
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return transactionResult, 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)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.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.GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime(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, clientTimezone, 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
@@ -296,27 +490,27 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any,
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionStatisticsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
var allTagIds []int64
noTags := statisticReq.TagIds == "none"
noTags := statisticReq.TagFilter == models.TransactionNoTagFilterValue
var tagFilters []*models.TransactionTagFilter
if !noTags {
allTagIds, err = a.transactionTags.GetTagIds(statisticReq.TagIds)
tagFilters, err = models.ParseTransactionTagFilter(statisticReq.TagFilter)
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsHandler] get transaction tag ids error, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionStatisticsHandler] parse transaction tag filters error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
}
uid := c.GetCurrentUid()
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, allTagIds, noTags, statisticReq.TagFilterType, statisticReq.Keyword, utcOffset, statisticReq.UseTransactionTimezone)
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalInflowAndOutflow(c, uid, statisticReq.StartTime, statisticReq.EndTime, tagFilters, noTags, statisticReq.Keyword, clientTimezone, 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())
@@ -337,6 +531,11 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any,
AccountId: totalAmountItem.AccountId,
TotalAmount: totalAmountItem.Amount,
}
if totalAmountItem.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || totalAmountItem.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
statisticResp.Items[i].RelatedAccountId = totalAmountItem.RelatedAccountId
statisticResp.Items[i].RelatedAccountType, _ = totalAmountItem.Type.ToTransactionRelatedAccountType()
}
}
return statisticResp, nil
@@ -352,10 +551,10 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsTrendsHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionStatisticsTrendsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -366,20 +565,20 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
return nil, errs.Or(err, errs.ErrOperationFailed)
}
var allTagIds []int64
noTags := statisticTrendsReq.TagIds == "none"
noTags := statisticTrendsReq.TagFilter == models.TransactionNoTagFilterValue
var tagFilters []*models.TransactionTagFilter
if !noTags {
allTagIds, err = a.transactionTags.GetTagIds(statisticTrendsReq.TagIds)
tagFilters, err = models.ParseTransactionTagFilter(statisticTrendsReq.TagFilter)
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsTrendsHandler] get transaction tag ids error, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionStatisticsTrendsHandler] parse transaction tag filters error, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
}
uid := c.GetCurrentUid()
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, allTagIds, noTags, statisticTrendsReq.TagFilterType, statisticTrendsReq.Keyword, utcOffset, statisticTrendsReq.UseTransactionTimezone)
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyInflowAndOutflow(c, uid, startYear, startMonth, endYear, endMonth, tagFilters, noTags, statisticTrendsReq.Keyword, clientTimezone, 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())
@@ -402,6 +601,11 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
AccountId: totalAmountItem.AccountId,
TotalAmount: totalAmountItem.Amount,
}
if totalAmountItem.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || totalAmountItem.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
monthlyStatisticResp.Items[i].RelatedAccountId = totalAmountItem.RelatedAccountId
monthlyStatisticResp.Items[i].RelatedAccountType, _ = totalAmountItem.Type.ToTransactionRelatedAccountType()
}
}
statisticTrendsResp = append(statisticTrendsResp, monthlyStatisticResp)
@@ -412,6 +616,71 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
return statisticTrendsResp, nil
}
// TransactionStatisticsAssetTrendsHandler returns transaction statistics asset trends of current user
func (a *TransactionsApi) TransactionStatisticsAssetTrendsHandler(c *core.WebContext) (any, *errs.Error) {
var statisticAssetTrendsReq models.TransactionStatisticAssetTrendsRequest
err := c.ShouldBindQuery(&statisticAssetTrendsReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
maxTransactionTime := int64(0)
if statisticAssetTrendsReq.EndTime > 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(statisticAssetTrendsReq.EndTime)
}
minTransactionTime := int64(0)
if statisticAssetTrendsReq.StartTime > 0 {
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(statisticAssetTrendsReq.StartTime)
}
accountDailyBalances, err := a.transactions.GetAllAccountsDailyOpeningAndClosingBalance(c, uid, maxTransactionTime, minTransactionTime, clientTimezone)
if err != nil {
log.Errorf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] failed to get transactions from \"%d\" to \"%d\" for user \"uid:%d\", because %s", statisticAssetTrendsReq.StartTime, statisticAssetTrendsReq.EndTime, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
statisticAssetTrendsResp := make(models.TransactionStatisticAssetTrendsResponseItemSlice, 0)
for yearMonthDay, dailyAccountBalances := range accountDailyBalances {
dailyStatisticResp := &models.TransactionStatisticAssetTrendsResponseItem{
Year: yearMonthDay / 10000,
Month: (yearMonthDay % 10000) / 100,
Day: yearMonthDay % 100,
Items: make([]*models.TransactionStatisticAssetTrendsResponseDataItem, len(dailyAccountBalances)),
}
for i := 0; i < len(dailyAccountBalances); i++ {
accountBalance := dailyAccountBalances[i]
dailyStatisticResp.Items[i] = &models.TransactionStatisticAssetTrendsResponseDataItem{
AccountId: accountBalance.AccountId,
AccountOpeningBalance: accountBalance.AccountOpeningBalance,
AccountClosingBalance: accountBalance.AccountClosingBalance,
}
}
statisticAssetTrendsResp = append(statisticAssetTrendsResp, dailyStatisticResp)
}
sort.Sort(statisticAssetTrendsResp)
return statisticAssetTrendsResp, nil
}
// TransactionAmountsHandler returns transaction amounts of current user
func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionAmountsReq models.TransactionAmountsRequest
@@ -439,10 +708,29 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
return nil, errs.ErrQueryItemsTooMuch
}
utcOffset, err := c.GetClientTimezoneOffset()
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
}
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionAmountsHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionAmountsHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -461,7 +749,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, clientTimezone, 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())
@@ -542,10 +830,10 @@ func (a *TransactionsApi) TransactionGetHandler(c *core.WebContext) (any, *errs.
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionGetHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionGetHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -631,7 +919,7 @@ func (a *TransactionsApi) TransactionGetHandler(c *core.WebContext) (any, *errs.
}
}
transactionEditable := transaction.IsEditable(user, utcOffset, accountMap[transaction.AccountId], accountMap[transaction.RelatedAccountId])
transactionEditable := transaction.IsEditable(user, clientTimezone, accountMap[transaction.AccountId], accountMap[transaction.RelatedAccountId])
transactionTagIds := allTransactionTagIds[transaction.TransactionId]
transactionResp := transaction.ToTransactionInfoResponse(transactionTagIds, transactionEditable)
@@ -672,6 +960,13 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionCreateHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
tagIds, err := utils.StringArrayToInt64Array(transactionCreateReq.TagIds)
if err != nil {
@@ -699,7 +994,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
}
@@ -729,7 +1024,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
}
transaction := a.createNewTransactionModel(uid, &transactionCreateReq, c.ClientIP())
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, transactionCreateReq.UtcOffset)
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
if !transactionEditable {
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
@@ -802,6 +1097,13 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionModifyHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
tagIds, err := utils.StringArrayToInt64Array(transactionModifyReq.TagIds)
if err != nil {
@@ -847,6 +1149,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 {
@@ -907,8 +1217,8 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return nil, errs.ErrNothingWillBeUpdated
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, transaction.TimezoneUtcOffset)
newTransactionEditable := user.CanEditTransactionByTransactionTime(newTransaction.TransactionTime, transactionModifyReq.UtcOffset)
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
newTransactionEditable := user.CanEditTransactionByTransactionTime(newTransaction.TransactionTime, clientTimezone)
if !transactionEditable || !newTransactionEditable {
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
@@ -977,6 +1287,63 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return newTransactionResp, nil
}
// TransactionMoveAllBetweenAccountsHandler moves all transactions from one account to another account for current user
func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionMoveReq models.TransactionMoveBetweenAccountsRequest
err := c.ShouldBindJSON(&transactionMoveReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if transactionMoveReq.FromAccountId == transactionMoveReq.ToAccountId {
return nil, errs.ErrCannotMoveTransactionToSameAccount
}
uid := c.GetCurrentUid()
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, []int64{transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId})
if err != nil {
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to get accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
fromAccount, exists := accountMap[transactionMoveReq.FromAccountId]
if !exists {
return nil, errs.ErrSourceAccountNotFound
}
toAccount, exists := accountMap[transactionMoveReq.ToAccountId]
if !exists {
return nil, errs.ErrDestinationAccountNotFound
}
if fromAccount.Hidden || toAccount.Hidden {
return nil, errs.ErrCannotMoveTransactionFromOrToHiddenAccount
}
if fromAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS || toAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
return nil, errs.ErrCannotMoveTransactionFromOrToParentAccount
}
if fromAccount.Currency != toAccount.Currency {
return nil, errs.ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies
}
err = a.transactions.MoveAllTransactionsBetweenAccounts(c, uid, transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId)
if err != nil {
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to move all transactions from account \"id:%d\" to account \"id:%d\" for user \"uid:%d\", because %s", transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] user \"uid:%d\" has moved all transactions from account \"id:%d\" to account \"id:%d\" successfully", uid, transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId)
return true, nil
}
// TransactionDeleteHandler deletes an existed transaction by request parameters for current user
func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var transactionDeleteReq models.TransactionDeleteRequest
@@ -987,10 +1354,10 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionDeleteHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionDeleteHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -1017,7 +1384,7 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTypeInvalid
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, utcOffset)
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
if !transactionEditable {
return nil, errs.ErrCannotDeleteTransactionWithThisTransactionTime
@@ -1121,10 +1488,10 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
return nil, errs.ErrParameterInvalid
}
utcOffset, err := c.GetClientTimezoneOffset()
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] cannot get client timezone offset, because %s", err.Error())
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
@@ -1136,6 +1503,15 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
fileType := fileTypes[0]
textualOptions := form.Value["options"]
textualOption := ""
if len(textualOptions) > 0 {
textualOption = textualOptions[0]
}
additionalOptions := converter.ParseImporterOptions(textualOption)
var dataImporter converter.TransactionDataImporter
if converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
@@ -1310,9 +1686,9 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
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)
parsedTransactions, _, _, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, clientTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse imported data for user \"uid:%d\", because %s", user.Uid, err.Error())
@@ -1343,6 +1719,13 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionImportHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && transactionImportReq.ClientSessionId != "" {
@@ -1388,7 +1771,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
}
@@ -1428,7 +1811,7 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
for i := 0; i < len(transactionImportReq.Transactions); i++ {
transactionCreateReq := transactionImportReq.Transactions[i]
transaction := a.createNewTransactionModel(uid, transactionCreateReq, c.ClientIP())
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, transactionCreateReq.UtcOffset)
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
if !transactionEditable {
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
@@ -1545,7 +1928,7 @@ func (a *TransactionsApi) getTransactionTagInfoResponses(tagIds []int64, allTran
return allTags
}
func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, user *models.User, transactions []*models.Transaction, utcOffset int16, withPictures bool, trimAccount bool, trimCategory bool, trimTag bool) (models.TransactionInfoResponseSlice, error) {
func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, user *models.User, transactions []*models.Transaction, clientTimezone *time.Location, withPictures bool, trimAccount bool, trimCategory bool, trimTag bool) (models.TransactionInfoResponseSlice, error) {
uid := user.Uid
transactionIds := make([]int64, len(transactions))
accountIds := make([]int64, 0, len(transactions)*2)
@@ -1624,7 +2007,7 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
transaction = a.transactions.GetRelatedTransferTransaction(transaction)
}
transactionEditable := transaction.IsEditable(user, utcOffset, allAccounts[transaction.AccountId], allAccounts[transaction.RelatedAccountId])
transactionEditable := transaction.IsEditable(user, clientTimezone, allAccounts[transaction.AccountId], allAccounts[transaction.RelatedAccountId])
transactionTagIds := allTransactionTagIds[transaction.TransactionId]
result[i] = transaction.ToTransactionInfoResponse(transactionTagIds, transactionEditable)
+2 -1
View File
@@ -85,7 +85,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.WebCo
return nil, errs.ErrNotPermittedToPerformThisAction
}
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user)
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user, c.GetClientLocale())
if err != nil {
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor secret, because %s", err.Error())
@@ -205,6 +205,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.WebCo
c.SetTextualToken(token)
c.SetTokenClaims(claims)
c.SetTokenContext("")
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
+99 -84
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -74,115 +75,129 @@ func (a *UserApplicationCloudSettingsApi) ApplicationSettingsUpdateHandler(c *co
return false, errs.ErrNotPermittedToPerformThisAction
}
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
var userApplicationCloudSettings *models.UserApplicationCloudSetting
if err != nil {
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
return false, errs.Or(err, errs.ErrOperationFailed)
}
// Retry up to 3 times
for i := 0; i < 3; i++ {
userApplicationCloudSettings, err = a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
if userApplicationCloudSettings != nil {
for _, setting := range userApplicationCloudSettings.Settings {
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
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)
}
}
// Check if the full update settings are the same as the existing settings
if userAppCloudSettingUpdateReq.FullUpdate {
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
needUpdate := false
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 {
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
if !exists || oldSetting.SettingValue != setting.SettingValue {
needUpdate = true
break
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 {
return false, errs.ErrNothingWillBeUpdated
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
}
}
} 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]
newApplicationCloudSettingsMap[setting.SettingKey] = setting
}
for settingKey, setting := range newApplicationCloudSettingsMap {
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
if !exists {
needUpdate = false
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync", setting.SettingKey)
} 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", setting.SettingKey, setting.SettingValue)
}
}
if !needUpdate {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\"", uid)
return false, 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", uid)
} 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", uid)
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", settingKey)
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\"", settingKey, setting.SettingValue)
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
}
} 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\"", settingKey, setting.SettingValue)
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\", because %s", settingKey, setting.SettingValue, err.Error())
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
}
} else {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\"", settingKey, settingType)
continue
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
}
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
}
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice, userAppCloudSettingUpdateReq.FullUpdate, lastUpdateTime)
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice)
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())
+108
View File
@@ -0,0 +1,108 @@
package api
import (
"sort"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
"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"
)
// UserExternalAuthsApi represents user external auth api
type UserExternalAuthsApi struct {
users *services.UserService
userExternalAuths *services.UserExternalAuthService
}
// Initialize a user external auth api singleton instance
var (
UserExternalAuths = &UserExternalAuthsApi{
users: services.Users,
userExternalAuths: services.UserExternalAuths,
}
)
// ExternalAuthListHanlder returns external authentications list of current user
func (a *UserExternalAuthsApi) ExternalAuthListHanlder(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
userExternalAuths, err := a.userExternalAuths.GetUserAllExternalAuthsByUid(c, uid)
if err != nil {
log.Errorf(c, "[user_external_auths.ExternalAuthListHanlder] failed to get all external authentications for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
userExternalAuthResps := make(models.UserExternalAuthInfoResponsesSlice, 0, len(userExternalAuths)+1)
currentExternalAuthType := oauth2.GetExternalUserAuthType()
hasCurrentExternalAuth := false
for i := 0; i < len(userExternalAuths); i++ {
userExternalAuth := userExternalAuths[i]
if userExternalAuth.ExternalAuthType == currentExternalAuthType {
hasCurrentExternalAuth = true
}
userExternalAuthResps = append(userExternalAuthResps, userExternalAuth.ToUserExternalAuthInfoResponse())
}
if !hasCurrentExternalAuth {
userExternalAuthResps = append(userExternalAuthResps, &models.UserExternalAuthInfoResponse{
ExternalAuthCategory: currentExternalAuthType.GetCategory(),
ExternalAuthType: currentExternalAuthType,
Linked: false,
})
}
sort.Sort(userExternalAuthResps)
return userExternalAuthResps, nil
}
// UnlinkExternalAuthHandler unlinks external authentication for current user
func (a *UserExternalAuthsApi) UnlinkExternalAuthHandler(c *core.WebContext) (any, *errs.Error) {
var externalAuthLinkReq models.UserExternalAuthUnlinkRequest
err := c.ShouldBindJSON(&externalAuthLinkReq)
if err != nil {
log.Warnf(c, "[user_external_auths.UnlinkExternalAuthHandler] 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, "[user_external_auths.UnlinkExternalAuthHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(externalAuthLinkReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UNLINK_THIRD_PARTY_LOGIN) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
externalAuthType := core.UserExternalAuthType(externalAuthLinkReq.ExternalAuthType)
if !externalAuthType.IsValid() {
return nil, errs.ErrUserExternalAuthNotFound
}
err = a.userExternalAuths.DeleteUserExternalAuth(c, uid, externalAuthType)
if err != nil {
log.Errorf(c, "[user_external_auths.UnlinkExternalAuthHandler] failed to unlink external authentication \"%s\" for user \"uid:%d\", because %s", externalAuthLinkReq.ExternalAuthType, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}
+42 -12
View File
@@ -83,7 +83,7 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
}
err = a.users.CreateUser(c, user)
err = a.users.CreateUser(c, user, false)
if err != nil {
log.Errorf(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
@@ -142,8 +142,9 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
authResp.Token = token
c.SetTextualToken(token)
c.SetTokenClaims(claims)
c.SetTokenContext("")
log.Infof(c, "[users.UserRegisterHandler] user \"uid:%d\" has logined, token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[users.UserRegisterHandler] user \"uid:%d\" has logged in, token will be expired at %d", user.Uid, claims.ExpiresAt)
return authResp, nil
}
@@ -205,6 +206,7 @@ func (a *UsersApi) UserEmailVerifyHandler(c *core.WebContext) (any, *errs.Error)
c.SetTextualToken(token)
c.SetTokenClaims(claims)
c.SetTokenContext("")
log.Infof(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt)
}
@@ -275,7 +277,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
return nil, errs.ErrNotPermittedToPerformThisAction
}
if !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
if user.Password != "" && !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
return nil, errs.ErrUserPasswordWrong
}
@@ -359,6 +361,24 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
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
@@ -404,6 +424,24 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
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
@@ -431,15 +469,6 @@ 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
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
}
if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
@@ -561,6 +590,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
resp.NewToken = token
c.SetTextualToken(token)
c.SetTokenClaims(claims)
c.SetTokenContext("")
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
+13
View File
@@ -0,0 +1,13 @@
package data
import "github.com/mayswind/ezbookkeeping/pkg/core"
// OAuth2UserInfo represents the user info retrieved from OAuth 2.0 provider
type OAuth2UserInfo struct {
UserName string
Email string
NickName string
LanguageCode string
CurrencyCode string
FirstDayOfWeek core.WeekDay
}
+122
View File
@@ -0,0 +1,122 @@
package oauth2
import (
"net/http"
"golang.org/x/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/gitea"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/github"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/nextcloud"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/oidc"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// OAuth2Container contains the current OAuth 2.0 authentication provider
type OAuth2Container struct {
current provider.OAuth2Provider
usePKCE bool
oauth2HttpClient *http.Client
externalUserAuthType core.UserExternalAuthType
}
// Initialize a OAuth 2.0 container singleton instance
var (
Container = &OAuth2Container{}
)
// InitializeOAuth2Provider initializes the current OAuth 2.0 provider according to the config
func InitializeOAuth2Provider(config *settings.Config) error {
if !config.EnableOAuth2Login {
return nil
}
if config.OAuth2ClientID == "" || config.OAuth2ClientSecret == "" || config.OAuth2UserIdentifier == "" || config.OAuth2Provider == "" {
return errs.ErrInvalidOAuth2Config
}
var err error
var oauth2Provider provider.OAuth2Provider
var externalUserAuthType core.UserExternalAuthType
redirectUrl := config.RootUrl + "oauth2/callback"
if config.OAuth2Provider == settings.OAuth2ProviderOIDC {
oauth2Provider, err = oidc.NewOIDCProvider(config, redirectUrl)
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_OIDC
} else if config.OAuth2Provider == settings.OAuth2ProviderNextcloud {
oauth2Provider, err = nextcloud.NewNextcloudOAuth2Provider(config, redirectUrl)
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD
} else if config.OAuth2Provider == settings.OAuth2ProviderGitea {
oauth2Provider, err = gitea.NewGiteaOAuth2Provider(config, redirectUrl)
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITEA
} else if config.OAuth2Provider == settings.OAuth2ProviderGithub {
oauth2Provider, err = github.NewGithubOAuth2Provider(config, redirectUrl)
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB
} else {
return errs.ErrInvalidOAuth2Provider
}
if err != nil {
return err
}
Container.current = oauth2Provider
Container.usePKCE = config.OAuth2UsePKCE
Container.oauth2HttpClient = httpclient.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, settings.GetUserAgent(), config.EnableDebugLog)
Container.externalUserAuthType = externalUserAuthType
return nil
}
// GetOAuth2AuthUrl returns the OAuth 2.0 authentication url
func GetOAuth2AuthUrl(c core.Context, state string, verifier string) (string, error) {
if Container.current == nil {
return "", errs.ErrOAuth2NotEnabled
}
var opts []oauth2.AuthCodeOption
if Container.usePKCE {
opts = append(opts, oauth2.S256ChallengeOption(verifier))
}
return Container.current.GetOAuth2AuthUrl(wrapOAuth2Context(c, Container.oauth2HttpClient), state, opts...)
}
// GetOAuth2Token exchanges the authorization code for an OAuth 2.0 token
func GetOAuth2Token(c core.Context, code string, verifier string) (*oauth2.Token, error) {
if Container.current == nil || Container.oauth2HttpClient == nil {
return nil, errs.ErrOAuth2NotEnabled
}
var opts []oauth2.AuthCodeOption
if Container.usePKCE {
opts = append(opts, oauth2.VerifierOption(verifier))
}
return Container.current.GetOAuth2Token(wrapOAuth2Context(c, Container.oauth2HttpClient), code, opts...)
}
// GetOAuth2UserInfo retrieves the OAuth 2.0 user info using the provided OAuth 2.0 token
func GetOAuth2UserInfo(c core.Context, token *oauth2.Token) (*data.OAuth2UserInfo, error) {
if Container.current == nil || Container.oauth2HttpClient == nil {
return nil, errs.ErrOAuth2NotEnabled
}
if token == nil {
return nil, errs.ErrInvalidOAuth2Token
}
return Container.current.GetUserInfo(wrapOAuth2Context(c, Container.oauth2HttpClient), token)
}
// GetExternalUserAuthType returns the external user auth type of the current OAuth 2.0 provider
func GetExternalUserAuthType() core.UserExternalAuthType {
return Container.externalUserAuthType
}
+31
View File
@@ -0,0 +1,31 @@
package oauth2
import (
"net/http"
"golang.org/x/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/core"
)
// OAuth2Context represents the context for OAuth 2.0 operations
type OAuth2Context struct {
core.Context
httpClient *http.Client
}
// Value returns the value associated with key
func (c *OAuth2Context) Value(key any) any {
if key == oauth2.HTTPClient {
return c.httpClient
}
return c.Context.Value(key)
}
func wrapOAuth2Context(ctx core.Context, httpClient *http.Client) core.Context {
return &OAuth2Context{
Context: ctx,
httpClient: httpClient,
}
}
@@ -0,0 +1,108 @@
package common
import (
"io"
"net/http"
"golang.org/x/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// CommonOAuth2Provider represents common OAuth 2.0 provider
type CommonOAuth2Provider struct {
provider.OAuth2Provider
oauth2Config *oauth2.Config
dataSource CommonOAuth2DataSource
}
// CommonOAuth2DataSource defines the structure of OAuth 2.0 data source
type CommonOAuth2DataSource interface {
// GetAuthUrl returns the authentication url of the data source
GetAuthUrl() string
// GetTokenUrl returns the token url of the data source
GetTokenUrl() string
// GetUserInfoRequest returns the user info request of the data source
GetUserInfoRequest() (*http.Request, error)
// GetScopes returns the scopes required by the data source
GetScopes() []string
// ParseUserInfo returns the user info by parsing the response body
ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error)
}
// GetOAuth2AuthUrl returns the authentication url of the common OAuth 2.0 provider
func (p *CommonOAuth2Provider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
return p.oauth2Config.AuthCodeURL(state, opts...), nil
}
// GetOAuth2Token returns the OAuth 2.0 token of the common OAuth 2.0 provider
func (p *CommonOAuth2Provider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return p.oauth2Config.Exchange(c, code, opts...)
}
// GetUserInfo returns the user info by the common OAuth 2.0 provider
func (p *CommonOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
req, err := p.dataSource.GetUserInfoRequest()
if err != nil {
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info request, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
oauth2Client := oauth2.NewClient(c, oauth2.StaticTokenSource(oauth2Token))
req = req.WithContext(httpclient.CustomHttpResponseLog(c, func(data []byte) {
log.Debugf(c, "[common_oauth2_provider.GetUserInfo] response is %s", data)
}))
resp, err := oauth2Client.Do(req)
if err != nil {
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode)
return nil, errs.ErrFailedToRequestRemoteApi
}
return p.dataSource.ParseUserInfo(c, body)
}
// GetDataSource returns the data source of the common OAuth 2.0 provider
func (p *CommonOAuth2Provider) GetDataSource() CommonOAuth2DataSource {
return p.dataSource
}
// NewCommonOAuth2Provider returns a new common OAuth 2.0 provider
func NewCommonOAuth2Provider(config *settings.Config, redirectUrl string, dataSource CommonOAuth2DataSource) *CommonOAuth2Provider {
oauth2Config := &oauth2.Config{
ClientID: config.OAuth2ClientID,
ClientSecret: config.OAuth2ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: dataSource.GetAuthUrl(),
TokenURL: dataSource.GetTokenUrl(),
},
RedirectURL: redirectUrl,
Scopes: dataSource.GetScopes(),
}
return &CommonOAuth2Provider{
oauth2Config: oauth2Config,
dataSource: dataSource,
}
}
@@ -0,0 +1,95 @@
package gitea
import (
"encoding/json"
"net/http"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
type giteaUserInfoResponse struct {
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
}
// GiteaOAuth2DataSource represents Gitea OAuth 2.0 data source
type GiteaOAuth2DataSource struct {
common.CommonOAuth2DataSource
baseUrl string
}
// GetAuthUrl returns the authentication url of the Gitea data source
func (s *GiteaOAuth2DataSource) GetAuthUrl() string {
// Reference: https://docs.gitea.com/development/oauth2-provider
return s.baseUrl + "login/oauth/authorize"
}
// GetTokenUrl returns the token url of the Gitea data source
func (s *GiteaOAuth2DataSource) GetTokenUrl() string {
// Reference: https://docs.gitea.com/development/oauth2-provider
return s.baseUrl + "login/oauth/access_token"
}
// GetUserInfoRequest returns the user info request of the Gitea data source
func (s *GiteaOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
// Reference: https://gitea.com/api/swagger#/user/userGetCurrent
req, err := http.NewRequest("GET", s.baseUrl+"api/v1/user", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
return req, nil
}
// GetScopes returns the scopes required by the Gitea provider
func (s *GiteaOAuth2DataSource) GetScopes() []string {
return []string{"read:user"}
}
// ParseUserInfo returns the user info by parsing the response body
func (s *GiteaOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
userInfoResp := &giteaUserInfoResponse{}
err := json.Unmarshal(body, &userInfoResp)
if err != nil {
log.Warnf(c, "[gitea_oauth2_datasource.ParseUserInfo] failed to parse user profile response body, because %s", err.Error())
return nil, errs.ErrCannotRetrieveUserInfo
}
if userInfoResp.Login == "" {
log.Warnf(c, "[gitea_oauth2_datasource.ParseUserInfo] invalid user profile response body")
return nil, errs.ErrCannotRetrieveUserInfo
}
return &data.OAuth2UserInfo{
UserName: userInfoResp.Login,
Email: userInfoResp.Email,
NickName: userInfoResp.FullName,
}, nil
}
// NewGiteaOAuth2Provider creates a new Gitea OAuth 2.0 provider instance
func NewGiteaOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
if len(config.OAuth2GiteaBaseUrl) < 1 {
return nil, errs.ErrInvalidOAuth2Config
}
baseUrl := config.OAuth2GiteaBaseUrl
if baseUrl[len(baseUrl)-1] != '/' {
baseUrl += "/"
}
return common.NewCommonOAuth2Provider(config, redirectUrl, &GiteaOAuth2DataSource{
baseUrl: baseUrl,
}), nil
}
@@ -0,0 +1,71 @@
package gitea
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
func TestNewGiteaOAuth2Provider(t *testing.T) {
provider, err := NewGiteaOAuth2Provider(&settings.Config{
OAuth2GiteaBaseUrl: "https://example.com/",
}, "")
assert.Nil(t, err)
assert.Equal(t, "https://example.com/login/oauth/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
assert.Equal(t, "https://example.com/login/oauth/access_token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
provider, err = NewGiteaOAuth2Provider(&settings.Config{
OAuth2GiteaBaseUrl: "https://example.com",
}, "")
assert.Nil(t, err)
assert.Equal(t, "https://example.com/login/oauth/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
assert.Equal(t, "https://example.com/login/oauth/access_token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
provider, err = NewGiteaOAuth2Provider(&settings.Config{}, "")
assert.Equal(t, errs.ErrInvalidOAuth2Config, err)
}
func TestGiteaOAuth2Datasource_GetUserInfoRequest(t *testing.T) {
datasource := &GiteaOAuth2DataSource{baseUrl: "https://example.com/"}
req, err := datasource.GetUserInfoRequest()
assert.Nil(t, err)
assert.Equal(t, "GET", req.Method)
assert.Equal(t, "https://example.com/api/v1/user", req.URL.String())
assert.Equal(t, "application/json", req.Header.Get("Accept"))
}
func TestGiteaOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
datasource := &GiteaOAuth2DataSource{}
responseContent := `{
"login": "user1",
"full_name": "User",
"email": "user1@example.com"
}`
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Nil(t, err)
assert.Equal(t, "user1", info.UserName)
assert.Equal(t, "user1@example.com", info.Email)
assert.Equal(t, "User", info.NickName)
}
func TestGiteaOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
datasource := &GiteaOAuth2DataSource{}
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestGiteaOAuth2Datasource_ParseUserInfo_EmptyLogin(t *testing.T) {
datasource := &GiteaOAuth2DataSource{}
responseContent := `{"login": ""}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
@@ -0,0 +1,193 @@
package github
import (
"encoding/json"
"io"
"net/http"
"golang.org/x/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const githubOAuth2AuthUrl = "https://github.com/login/oauth/authorize" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
const githubOAuth2TokenUrl = "https://github.com/login/oauth/access_token" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
const githubUserProfileApiUrl = "https://api.github.com/user" // Reference: https://docs.github.com/en/rest/users/users
const githubUserEmailApiUrl = "https://api.github.com/user/emails" // Reference: https://docs.github.com/en/rest/users/emails
var githubOAuth2Scopes = []string{"user:email"}
type githubUserProfileResponse struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
}
type githubUserEmailsResponse struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
// GithubOAuth2Provider represents Github OAuth 2.0 provider
type GithubOAuth2Provider struct {
provider.OAuth2Provider
oauth2Config *oauth2.Config
}
// GetOAuth2AuthUrl returns the authentication url of the GitHub OAuth 2.0 provider
func (p *GithubOAuth2Provider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
return p.oauth2Config.AuthCodeURL(state, opts...), nil
}
// GetOAuth2Token returns the OAuth 2.0 token of the GitHub OAuth 2.0 provider
func (p *GithubOAuth2Provider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return p.oauth2Config.Exchange(c, code, opts...)
}
// GetUserInfo returns the user info by the Github OAuth 2.0 provider
func (p *GithubOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
// first get user name and nick name from user profile
req, err := p.buildAPIRequest(githubUserProfileApiUrl)
if err != nil {
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info request, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
oauth2Client := oauth2.NewClient(c, oauth2.StaticTokenSource(oauth2Token))
req = req.WithContext(httpclient.CustomHttpResponseLog(c, func(data []byte) {
log.Debugf(c, "[github_oauth2_provider.GetUserInfo] user profile response is %s", data)
}))
resp, err := oauth2Client.Do(req)
if err != nil {
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode)
return nil, errs.ErrFailedToRequestRemoteApi
}
userProfileResp, err := p.parseUserProfile(c, body)
if err != nil {
return nil, err
}
// then get user primary email
req, err = p.buildAPIRequest(githubUserEmailApiUrl)
if err != nil {
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails request, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
req = req.WithContext(httpclient.CustomHttpResponseLog(c, func(data []byte) {
log.Debugf(c, "[github_oauth2_provider.GetUserInfo] user emails response is %s", data)
}))
resp, err = oauth2Client.Do(req)
if err != nil {
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails response, because %s", err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails response, because response code is %d", resp.StatusCode)
return nil, errs.ErrFailedToRequestRemoteApi
}
email, err := p.parsePrimaryEmail(c, body)
if err != nil {
return nil, err
}
return &data.OAuth2UserInfo{
UserName: userProfileResp.Login,
Email: email,
NickName: userProfileResp.Name,
}, nil
}
func (p *GithubOAuth2Provider) parseUserProfile(c core.Context, body []byte) (*githubUserProfileResponse, error) {
userProfileResp := &githubUserProfileResponse{}
err := json.Unmarshal(body, &userProfileResp)
if err != nil {
log.Warnf(c, "[github_oauth2_provider.parseUserProfile] failed to parse user profile response body, because %s", err.Error())
return nil, errs.ErrCannotRetrieveUserInfo
}
if userProfileResp.Login == "" {
log.Warnf(c, "[github_oauth2_provider.parseUserProfile] invalid user profile response body")
return nil, errs.ErrCannotRetrieveUserInfo
}
return userProfileResp, nil
}
func (p *GithubOAuth2Provider) parsePrimaryEmail(c core.Context, body []byte) (string, error) {
emailsResp := make([]githubUserEmailsResponse, 0)
err := json.Unmarshal(body, &emailsResp)
if err != nil {
log.Warnf(c, "[github_oauth2_provider.parsePrimaryEmail] failed to parse user emails response body, because %s", err.Error())
return "", errs.ErrCannotRetrieveUserInfo
}
for _, emailEntry := range emailsResp {
if emailEntry.Primary && emailEntry.Verified {
return emailEntry.Email, nil
}
}
return "", nil
}
func (p *GithubOAuth2Provider) buildAPIRequest(url string) (*http.Request, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
return req, nil
}
// NewGithubOAuth2Provider creates a new Github OAuth 2.0 provider instance
func NewGithubOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
oauth2Config := &oauth2.Config{
ClientID: config.OAuth2ClientID,
ClientSecret: config.OAuth2ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: githubOAuth2AuthUrl,
TokenURL: githubOAuth2TokenUrl,
},
RedirectURL: redirectUrl,
Scopes: githubOAuth2Scopes,
}
return &GithubOAuth2Provider{
oauth2Config: oauth2Config,
}, nil
}
@@ -0,0 +1,95 @@
package github
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestGithubOAuth2Datasource_ParseUserProfile_Success(t *testing.T) {
datasource := &GithubOAuth2Provider{}
responseContent := `{
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false,
"name": "monalisa octocat",
"company": "GitHub",
"blog": "https://github.com/blog",
"location": "San Francisco",
"email": "octocat@github.com",
"hireable": false,
"bio": "There once was...",
"twitter_username": "monatheoctocat",
"public_repos": 2,
"public_gists": 1,
"followers": 20,
"following": 0,
"created_at": "2008-01-14T04:33:35Z",
"updated_at": "2008-01-14T04:33:35Z",
"private_gists": 81,
"total_private_repos": 100,
"owned_private_repos": 100,
"disk_usage": 10000,
"collaborators": 8,
"two_factor_authentication": true,
"plan": {
"name": "Medium",
"space": 400,
"private_repos": 20,
"collaborators": 0
}
}`
info, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent))
assert.Nil(t, err)
assert.Equal(t, "octocat", info.Login)
assert.Equal(t, "monalisa octocat", info.Name)
}
func TestGithubOAuth2Datasource_ParseUserProfile_EmptyLogin(t *testing.T) {
datasource := &GithubOAuth2Provider{}
responseContent := `{"login": ""}`
_, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestGithubOAuth2Datasource_ParsePrimaryEmail(t *testing.T) {
datasource := &GithubOAuth2Provider{}
responseContent := `[
{
"email": "foo@bar.com",
"primary": false,
"verified": true,
"visibility": null
},
{
"email": "octocat@github.com",
"primary": true,
"verified": true,
"visibility": "public"
}
]`
email, err := datasource.parsePrimaryEmail(core.NewNullContext(), []byte(responseContent))
assert.Nil(t, err)
assert.Equal(t, "octocat@github.com", email)
}
@@ -0,0 +1,114 @@
package nextcloud
import (
"encoding/json"
"net/http"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
type nextcloudUserInfoResponse struct {
OCS *struct {
Meta *struct {
Status string `json:"status"`
StatusCode int `json:"statuscode"`
} `json:"meta"`
Data *struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display-name"`
} `json:"data"`
} `json:"ocs"`
}
// NextcloudOAuth2DataSource represents Nextcloud OAuth 2.0 data source
type NextcloudOAuth2DataSource struct {
common.CommonOAuth2DataSource
baseUrl string
}
// GetAuthUrl returns the authentication url of the Nextcloud data source
func (s *NextcloudOAuth2DataSource) GetAuthUrl() string {
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-login_redirector-authorize
return s.baseUrl + "apps/oauth2/authorize"
}
// GetTokenUrl returns the token url of the Nextcloud data source
func (s *NextcloudOAuth2DataSource) GetTokenUrl() string {
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-oauth_api-get-token
return s.baseUrl + "apps/oauth2/api/v1/token"
}
// GetUserInfoRequest returns the user info request of the Nextcloud data source
func (s *NextcloudOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/provisioning_api-users-get-current-user
req, err := http.NewRequest("GET", s.baseUrl+"ocs/v2.php/cloud/user", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("OCS-APIRequest", "true")
return req, nil
}
// GetScopes returns the scopes required by the Nextcloud provider
func (s *NextcloudOAuth2DataSource) GetScopes() []string {
return []string{}
}
// ParseUserInfo returns the user info by parsing the response body
func (s *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
userInfoResp := &nextcloudUserInfoResponse{}
err := json.Unmarshal(body, &userInfoResp)
if err != nil {
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] failed to parse user info response body, because %s", err.Error())
return nil, errs.ErrCannotRetrieveUserInfo
}
if userInfoResp.OCS == nil || userInfoResp.OCS.Meta == nil || userInfoResp.OCS.Data == nil {
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] invalid user info response body")
return nil, errs.ErrCannotRetrieveUserInfo
}
if userInfoResp.OCS.Meta.StatusCode != 200 {
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info response status code is %d", userInfoResp.OCS.Meta.StatusCode)
return nil, errs.ErrCannotRetrieveUserInfo
}
if userInfoResp.OCS.Data.ID == "" {
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info id is empty")
return nil, errs.ErrCannotRetrieveUserInfo
}
return &data.OAuth2UserInfo{
UserName: userInfoResp.OCS.Data.ID,
Email: userInfoResp.OCS.Data.Email,
NickName: userInfoResp.OCS.Data.DisplayName,
}, nil
}
// NewNextcloudOAuth2Provider creates a new Nextcloud OAuth 2.0 provider instance
func NewNextcloudOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
if len(config.OAuth2NextcloudBaseUrl) < 1 {
return nil, errs.ErrInvalidOAuth2Config
}
baseUrl := config.OAuth2NextcloudBaseUrl
if baseUrl[len(baseUrl)-1] != '/' {
baseUrl += "/"
}
return common.NewCommonOAuth2Provider(config, redirectUrl, &NextcloudOAuth2DataSource{
baseUrl: baseUrl,
}), nil
}
@@ -0,0 +1,116 @@
package nextcloud
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
func TestNewNextcloudOAuth2Provider(t *testing.T) {
provider, err := NewNextcloudOAuth2Provider(&settings.Config{
OAuth2NextcloudBaseUrl: "https://example.com/",
}, "")
assert.Nil(t, err)
assert.Equal(t, "https://example.com/apps/oauth2/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
assert.Equal(t, "https://example.com/apps/oauth2/api/v1/token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
provider, err = NewNextcloudOAuth2Provider(&settings.Config{
OAuth2NextcloudBaseUrl: "https://example.com/index.php",
}, "")
assert.Nil(t, err)
assert.Equal(t, "https://example.com/index.php/apps/oauth2/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
assert.Equal(t, "https://example.com/index.php/apps/oauth2/api/v1/token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
provider, err = NewNextcloudOAuth2Provider(&settings.Config{}, "")
assert.Equal(t, errs.ErrInvalidOAuth2Config, err)
}
func TestNextcloudOAuth2Datasource_GetUserInfoRequest(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{baseUrl: "https://example.com/"}
req, err := datasource.GetUserInfoRequest()
assert.Nil(t, err)
assert.Equal(t, "GET", req.Method)
assert.Equal(t, "https://example.com/ocs/v2.php/cloud/user", req.URL.String())
assert.Equal(t, "application/json", req.Header.Get("Accept"))
assert.Equal(t, "true", req.Header.Get("OCS-APIRequest"))
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{
"ocs": {
"meta": {
"status": "ok",
"statuscode": 200
},
"data": {
"id": "user1",
"email": "user1@example.com",
"display-name": "User"
}
}
}`
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Nil(t, err)
assert.Equal(t, "user1", info.UserName)
assert.Equal(t, "user1@example.com", info.Email)
assert.Equal(t, "User", info.NickName)
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_MissingFields(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{"ocs": {}}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_Non200StatusCode(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{
"ocs": {
"meta": {
"status": "error",
"statuscode": 400
},
"data": {}
}
}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
func TestNextcloudOAuth2Datasource_ParseUserInfo_EmptyID(t *testing.T) {
datasource := &NextcloudOAuth2DataSource{}
responseContent := `{
"ocs": {
"meta": {
"status": "ok",
"statuscode": 200
},
"data": {
"id": "",
"email": "user1@example.com",
"display-name": "User One"
}
}
}`
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
}
@@ -0,0 +1,20 @@
package provider
import (
"golang.org/x/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/core"
)
// OAuth2Provider defines the structure of OAuth 2.0 provider
type OAuth2Provider interface {
// GetOAuth2AuthUrl returns the authentication url of the provider
GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error)
// GetOAuth2Token returns the OAuth 2.0 token of the provider
GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
// GetUserInfo returns the user info
GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error)
}
@@ -0,0 +1,188 @@
package oidc
import (
"context"
"golang.org/x/oauth2"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// OIDCClaims represents OIDC claims
type OIDCClaims struct {
PreferredUserName string `json:"preferred_username"`
UserName string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
}
// OIDCProvider represents OIDC provider
type OIDCProvider struct {
provider.OAuth2Provider
oidcIssuerURL string
oidcCheckIssuerURL bool
redirectUrl string
oauth2ClientID string
oauth2ClientSecret string
oauth2Config *oauth2.Config
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
}
// GetOAuth2AuthUrl returns the authentication url of the OIDC provider
func (p *OIDCProvider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
oauth2Config, err := p.getOAuth2Config(c)
if err != nil {
return "", err
}
return oauth2Config.AuthCodeURL(state, opts...), nil
}
// GetOAuth2Token returns the OAuth 2.0 token of the OIDC provider
func (p *OIDCProvider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
oauth2Config, err := p.getOAuth2Config(c)
if err != nil {
return nil, err
}
return oauth2Config.Exchange(c, code, opts...)
}
// GetUserInfo returns the user info by the OIDC provider
func (p *OIDCProvider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
_, err := p.getOAuth2Config(c)
if err != nil {
return nil, err
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
log.Errorf(c, "[oidc_provider.GetUserInfo] missing \"id_token\" field in oauth 2.0 token")
return nil, errs.ErrInvalidOAuth2Token
}
idToken, err := p.oidcVerifier.Verify(c, rawIDToken)
if err != nil {
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to verify \"id_token\" field in oauth 2.0 token, because %s", err.Error())
return nil, errs.ErrInvalidOAuth2Token
}
var claims OIDCClaims
err = idToken.Claims(&claims)
if err != nil {
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to parse claims in oauth 2.0 token, because %s", err.Error())
return nil, errs.ErrInvalidOAuth2Token
}
userName := claims.PreferredUserName
email := claims.Email
nickName := claims.Name
if userName == "" || email == "" || nickName == "" {
userInfo, err := p.oidcProvider.UserInfo(httpclient.CustomHttpResponseLog(c, func(data []byte) {
log.Debugf(c, "[oidc_provider.GetUserInfo] response is %s", data)
}), oauth2.StaticTokenSource(oauth2Token))
if err != nil {
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to get user info, because %s", err.Error())
return nil, errs.ErrCannotRetrieveUserInfo
}
err = userInfo.Claims(&claims)
if err != nil {
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to parse user info, because %s", err.Error())
return nil, errs.ErrCannotRetrieveUserInfo
}
if userName == "" {
userName = claims.PreferredUserName
}
if userName == "" {
userName = claims.UserName
}
if email == "" {
email = claims.Email
}
if nickName == "" {
nickName = claims.Name
}
}
return &data.OAuth2UserInfo{
UserName: userName,
Email: email,
NickName: nickName,
}, nil
}
func (p *OIDCProvider) getOAuth2Config(c core.Context) (*oauth2.Config, error) {
if p.oauth2Config != nil {
return p.oauth2Config, nil
}
var ctx context.Context = c
if !p.oidcCheckIssuerURL {
ctx = oidc.InsecureIssuerURLContext(c, p.oidcIssuerURL)
}
oidcProvider, err := oidc.NewProvider(ctx, p.oidcIssuerURL)
if err != nil {
log.Errorf(c, "[oidc_provider.getOAuth2Config] failed to create oidc provider, because %s", err.Error())
return nil, err
}
oidcVerifier := oidcProvider.Verifier(&oidc.Config{
ClientID: p.oauth2ClientID,
SkipIssuerCheck: !p.oidcCheckIssuerURL,
})
oauth2Config := &oauth2.Config{
ClientID: p.oauth2ClientID,
ClientSecret: p.oauth2ClientSecret,
Endpoint: oidcProvider.Endpoint(),
RedirectURL: p.redirectUrl,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
p.oauth2Config = oauth2Config
p.oidcProvider = oidcProvider
p.oidcVerifier = oidcVerifier
return oauth2Config, nil
}
// NewOIDCProvider returns a new OIDC provider
func NewOIDCProvider(config *settings.Config, redirectUrl string) (*OIDCProvider, error) {
if len(config.OAuth2OIDCProviderIssuerURL) < 1 {
return nil, errs.ErrInvalidOAuth2Config
}
return &OIDCProvider{
oidcIssuerURL: config.OAuth2OIDCProviderIssuerURL,
oidcCheckIssuerURL: config.OAuth2OIDCProviderCheckIssuerURL,
redirectUrl: redirectUrl,
oauth2ClientID: config.OAuth2ClientID,
oauth2ClientSecret: config.OAuth2ClientSecret,
oauth2Config: nil,
}, nil
}
+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()
}
+50 -8
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
@@ -91,7 +92,7 @@ func (l *UserDataCli) AddNewUser(c *core.CliContext, username string, email stri
FeatureRestriction: l.CurrentConfig().DefaultFeatureRestrictions,
}
err := l.users.CreateUser(c, user)
err := l.users.CreateUser(c, user, false)
if err != nil {
log.CliErrorf(c, "[user_data.AddNewUser] failed to create user \"%s\", because %s", user.Username, err.Error())
@@ -405,7 +406,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, tokenType string) (*models.TokenRecord, string, error) {
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, tokenType string, expiresInSeconds int64) (*models.TokenRecord, string, error) {
if username == "" {
log.CliErrorf(c, "[user_data.CreateNewUserToken] user name is empty")
return nil, "", errs.ErrUsernameIsEmpty
@@ -421,7 +422,17 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, to
var token string
var tokenRecord *models.TokenRecord
if tokenType == "mcp" {
if tokenType == "api" {
if !l.CurrentConfig().EnableAPIToken {
return nil, "", errs.ErrAPITokenNotEnabled
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_GENERATE_API_TOKEN) {
return nil, "", errs.ErrNotPermittedToPerformThisAction
}
token, tokenRecord, err = l.tokens.CreateAPITokenViaCli(c, user, expiresInSeconds)
} else if tokenType == "mcp" {
if !l.CurrentConfig().EnableMCPServer {
return nil, "", errs.ErrMCPServerNotEnabled
}
@@ -430,9 +441,7 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, to
return nil, "", errs.ErrNotPermittedToPerformThisAction
}
token, tokenRecord, err = l.tokens.CreateMCPTokenViaCli(c, user)
} else if tokenType == "normal" {
token, tokenRecord, err = l.tokens.CreateTokenViaCli(c, user)
token, tokenRecord, err = l.tokens.CreateMCPTokenViaCli(c, user, expiresInSeconds)
} else {
return nil, "", errs.ErrParameterInvalid
}
@@ -445,6 +454,39 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, to
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 == "" {
@@ -777,7 +819,7 @@ func (l *UserDataCli) ImportTransaction(c *core.CliContext, username string, fil
return err
}
parsedTransactions, newAccounts, newSubExpenseCategories, newSubIncomeCategories, newSubTransferCategories, newTags, err := dataImporter.ParseImportedData(c, user, data, utils.GetTimezoneOffsetMinutes(time.Local), accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
parsedTransactions, newAccounts, newSubExpenseCategories, newSubIncomeCategories, newSubTransferCategories, newTags, err := dataImporter.ParseImportedData(c, user, data, time.Local, converter.DefaultImporterOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
if err != nil {
log.CliErrorf(c, "[user_data.ImportTransaction] failed to parse imported data for \"%s\", because %s", username, err.Error())
@@ -924,7 +966,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
}
@@ -10,7 +10,7 @@ var (
AlipayAppTransactionDataCsvFileImporter = &alipayAppTransactionDataCsvFileImporter{
alipayTransactionDataCsvFileImporter{
fileHeaderLine: "------------------------------------------------------------------------------------",
dataHeaderStartContent: "支付宝(中国)网络技术有限公司 电子客户回单",
dataHeaderStartContent: []string{"支付宝(中国)网络技术有限公司 电子客户回单", "支付宝支付科技有限公司 电子客户回单"},
originalColumnNames: alipayTransactionColumnNames{
timeColumnName: "交易时间",
categoryColumnName: "交易分类",
@@ -2,21 +2,18 @@ package alipay
import (
"bytes"
"encoding/csv"
"io"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"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{
@@ -51,17 +48,23 @@ type alipayTransactionColumnNames struct {
// alipayTransactionDataCsvFileImporter defines the structure of alipay csv importer for transaction data
type alipayTransactionDataCsvFileImporter struct {
fileHeaderLine string
dataHeaderStartContent string
dataHeaderStartContent []string
dataBottomEndLineRune rune
originalColumnNames alipayTransactionColumnNames
}
// ParseImportedData returns the imported data by parsing the alipay transaction csv data
func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]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) {
func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, 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.createNewAlipayBasicDataTable(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
}
dataTable, err := createNewAlipayTransactionBasicDataTable(ctx, csvDataTable, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
@@ -81,82 +84,5 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
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) createNewAlipayBasicDataTable(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.BasicDataTable, 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_data_csv_file_importer.createNewAlipayBasicDataTable] 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_data_csv_file_importer.createNewAlipayBasicDataTable] 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_data_csv_file_importer.createNewAlipayBasicDataTable] 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_data_csv_file_importer.createNewAlipayBasicDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
return dataTable, nil
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -8,6 +8,7 @@ import (
"golang.org/x/text/encoding/simplifiedchinese"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -15,7 +16,7 @@ import (
)
func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -35,7 +36,7 @@ func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
@@ -94,7 +95,7 @@ func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
}
func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -102,6 +103,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" +
@@ -111,7 +113,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
@@ -121,6 +123,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" +
@@ -130,7 +133,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
@@ -141,8 +144,48 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvestmentRefundTransaction(t *testing.T) {
importer := 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 := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, 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
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -159,7 +202,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
@@ -171,12 +214,12 @@ func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -193,12 +236,12 @@ func TestAlipayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -216,7 +259,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -232,7 +275,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -248,7 +291,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data3), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -265,7 +308,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data4), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -282,7 +325,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data5), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -299,7 +342,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data6), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data6), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -316,7 +359,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data7), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data7), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -325,7 +368,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
}
func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
converter := AlipayAppTransactionDataCsvFileImporter
importer := AlipayAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -346,7 +389,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
"2024-09-01 23:59:59,Test Category3,充值-普通充值,不计收支,0.05,交易成功,\n")
assert.Nil(t, err)
allNewTransactions, _, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 3, len(allNewTransactions))
@@ -365,7 +408,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
}
func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T) {
converter := AlipayAppTransactionDataCsvFileImporter
importer := AlipayAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -380,42 +423,114 @@ 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)
allNewTransactions, allNewAccounts, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, 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) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -432,7 +547,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -447,7 +562,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
allNewTransactions, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -455,7 +570,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
}
func TestAlipayCsvFileImporterParseImportedData_SkipClosedIncomeOrTransferTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -472,12 +587,12 @@ func TestAlipayCsvFileImporterParseImportedData_SkipClosedIncomeOrTransferTransa
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易关闭 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownProductTransferTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -493,12 +608,12 @@ func TestAlipayCsvFileImporterParseImportedData_SkipUnknownProductTransferTransa
"2024-09-01 23:59:59 ,xxxx ,0.05 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -514,12 +629,12 @@ func TestAlipayCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,xxxx ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -533,15 +648,15 @@ func TestAlipayCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T)
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(""), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
}
func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -557,7 +672,7 @@ func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing
"金额(元),收/支 ,交易状态 ,\n" +
"0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
@@ -568,7 +683,7 @@ func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing
"交易创建时间 ,收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data2), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Status Column
@@ -579,7 +694,7 @@ func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing
"交易创建时间 ,金额(元),收/支 ,\n" +
"2024-09-01 12:34:56 ,0.12 ,收入 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data3), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
@@ -590,12 +705,12 @@ func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing
"交易创建时间 ,金额(元),交易状态 ,\n" +
"2024-09-01 12:34:56 ,0.12 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data4), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
func TestAlipayCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
importer := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
@@ -609,6 +724,6 @@ func TestAlipayCsvFileImporterParseImportedData_NoTransactionData(t *testing.T)
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
@@ -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 utils.ContainsAnyString(row.GetData(0), dataHeaderStartContent) {
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
}
@@ -13,15 +13,21 @@ import (
const alipayTransactionDataStatusSuccessName = "交易成功"
const alipayTransactionDataStatusPaymentSuccessName = "支付成功"
const alipayTransactionDataStatusPendingGoodsReceiptConfirmationName = "等待确认收货"
const alipayTransactionDataStatusRepaymentSuccessName = "还款成功"
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
@@ -41,6 +47,7 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
if dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusPaymentSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusPendingGoodsReceiptConfirmationName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRepaymentSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusClosedName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRefundSuccessName &&
@@ -127,11 +134,29 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
}
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
@@ -143,6 +168,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
@@ -10,7 +10,7 @@ var (
AlipayWebTransactionDataCsvFileImporter = &alipayWebTransactionDataCsvFileImporter{
alipayTransactionDataCsvFileImporter{
fileHeaderLine: "支付宝交易记录明细查询",
dataHeaderStartContent: "交易记录明细列表",
dataHeaderStartContent: []string{"交易记录明细列表"},
dataBottomEndLineRune: '-',
originalColumnNames: alipayTransactionColumnNames{
timeColumnName: "交易创建时间",
@@ -1,15 +1,20 @@
package beancount
import (
"fmt"
"strconv"
"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,
@@ -17,6 +22,44 @@ var operatorPriority = map[rune]int{
'/': 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)
@@ -117,8 +160,8 @@ func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
return finalTokens, nil
}
func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
stack := make([]float64, 0)
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]
@@ -127,7 +170,7 @@ func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
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 0, errs.ErrInvalidAmountExpression
return nil, errs.ErrInvalidAmountExpression
}
// pop the top two operands
@@ -138,39 +181,41 @@ func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
stack = stack[:len(stack)-1]
// evaluate the operation
var result float64
result := big.NewInt(0)
switch token {
case "+":
result = a + b
result.Add(a, b)
case "-":
result = a - b
result.Sub(a, b)
case "*":
result = a * b
result.Mul(a, b)
result.Div(result, big.NewInt(normalizeFactor))
case "/":
if b == 0 {
if b.Int64() == 0 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression
return nil, errs.ErrInvalidAmountExpression
}
result = a / b
result.Mul(a, big.NewInt(normalizeFactor))
result.Div(result, b)
}
// push the result back to the stack
stack = append(stack, result)
default: // operands
num, err := strconv.ParseFloat(token, 64)
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 0, errs.ErrInvalidAmountExpression
return nil, errs.ErrInvalidAmountExpression
}
stack = append(stack, num)
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 0, errs.ErrInvalidAmountExpression
return nil, errs.ErrInvalidAmountExpression
}
return stack[0], nil
@@ -193,5 +238,5 @@ func evaluateBeancountAmountExpression(ctx core.Context, expr string) (string, e
return "", err
}
return fmt.Sprintf("%.2f", result), nil
return denormalizeNumberToTextualAmount(result), nil
}
@@ -1,6 +1,7 @@
package beancount
import (
"math/big"
"testing"
"github.com/stretchr/testify/assert"
@@ -97,23 +98,23 @@ func TestEvaluatePostfixExpr_ValidExpression(t *testing.T) {
result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"})
assert.Nil(t, err)
assert.Equal(t, float64(3), result)
assert.Equal(t, big.NewInt(3000000), result)
result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"})
assert.Nil(t, err)
assert.Equal(t, float64(2), result)
assert.Equal(t, big.NewInt(2000000), result)
result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"})
assert.Nil(t, err)
assert.Equal(t, float64(12), result)
assert.Equal(t, big.NewInt(12000000), result)
result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"})
assert.Nil(t, err)
assert.Equal(t, float64(3), result)
assert.Equal(t, big.NewInt(3000000), result)
result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"})
assert.Nil(t, err)
assert.Equal(t, float64(5), result)
assert.Equal(t, big.NewInt(5000000), result)
}
func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) {
@@ -179,6 +180,18 @@ func TestEvaluateBeancountAmountExpression_ValidExpression(t *testing.T) {
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) {
@@ -213,4 +226,10 @@ func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
_, 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)
}
@@ -1,6 +1,8 @@
package beancount
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -24,7 +26,7 @@ var (
)
// 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) {
func (c *beancountTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, 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 {
@@ -45,5 +47,5 @@ func (c *beancountTransactionDataImporter) ParseImportedData(ctx core.Context, u
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(beancountTransactionTypeNameMapping, "", "", BEANCOUNT_TRANSACTION_TAG_SEPARATOR)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -2,9 +2,11 @@ package beancount
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -12,7 +14,7 @@ import (
)
func TestBeancountTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -20,7 +22,7 @@ func TestBeancountTransactionDataFileParseImportedData_MinimumValidData(t *testi
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := importer.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"+
@@ -32,7 +34,7 @@ func TestBeancountTransactionDataFileParseImportedData_MinimumValidData(t *testi
" 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)
" Assets:TestAccount2 0.05 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
@@ -91,7 +93,7 @@ func TestBeancountTransactionDataFileParseImportedData_MinimumValidData(t *testi
}
func TestBeancountTransactionDataFileParseImportedData_MinimumValidData2(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -99,7 +101,7 @@ func TestBeancountTransactionDataFileParseImportedData_MinimumValidData2(t *test
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := importer.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Assets:TestAccount 123.45 CNY\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
@@ -111,7 +113,7 @@ func TestBeancountTransactionDataFileParseImportedData_MinimumValidData2(t *test
" 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)
" Assets:TestAccount -0.05 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
@@ -170,7 +172,7 @@ func TestBeancountTransactionDataFileParseImportedData_MinimumValidData2(t *test
}
func TestBeancountTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -178,15 +180,15 @@ func TestBeancountTransactionDataFileParseImportedData_ParseInvalidTime(t *testi
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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)
" Assets:TestAccount 123.45 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestBeancountTransactionDataFileParseImportedData_ParseValidCurrency(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -194,10 +196,10 @@ func TestBeancountTransactionDataFileParseImportedData_ParseValidCurrency(t *tes
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
allNewTransactions, allNewAccounts, _, _, _, _, err := importer.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)
" Assets:TestAccount2 0.84 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
@@ -222,7 +224,7 @@ func TestBeancountTransactionDataFileParseImportedData_ParseValidCurrency(t *tes
}
func TestBeancountTransactionDataFileParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -230,21 +232,21 @@ func TestBeancountTransactionDataFileParseImportedData_ParseInvalidAmount(t *tes
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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)
" Assets:TestAccount abc CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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)
" Assets:TestAccount 1/0 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestBeancountTransactionDataFileParseImportedData_ParseDescription(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -252,13 +254,13 @@ func TestBeancountTransactionDataFileParseImportedData_ParseDescription(t *testi
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
allNewTransactions, _, _, _, _, _, err := importer.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)
" Assets:TestAccount 0.12 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
@@ -269,7 +271,7 @@ func TestBeancountTransactionDataFileParseImportedData_ParseDescription(t *testi
}
func TestBeancountTransactionDataFileParseImportedData_InvalidTransaction(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -277,33 +279,33 @@ func TestBeancountTransactionDataFileParseImportedData_InvalidTransaction(t *tes
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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)
" Assets:TestAccount2 0.11 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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)
" Expenses:TestCategory2 0.11 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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)
" Income:TestCategory2 0.11 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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)
" Equity:TestCategory2 0.11 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
}
func TestBeancountTransactionDataFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -311,16 +313,16 @@ func TestBeancountTransactionDataFileParseImportedData_NotSupportedToParseSplitT
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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)
" Assets:TestAccount3 0.12 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotSupportedSplitTransactions.Message)
}
func TestBeancountTransactionDataFileParseImportedData_MissingTransactionRequiredData(t *testing.T) {
converter := BeancountTransactionDataImporter
importer := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -329,30 +331,30 @@ func TestBeancountTransactionDataFileParseImportedData_MissingTransactionRequire
}
// Missing Transaction Time
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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)
" Assets:TestAccount 123.45 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
// Missing Account Name
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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)
" 123.45 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
// Missing Amount
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(
"2024-09-01 * \"narration\"\n"+
" Equity:Opening-Balances\n"+
" Assets:TestAccount\n"), 0, nil, nil, nil, nil, nil)
" Assets:TestAccount\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
// Missing Commodity
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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)
" Assets:TestAccount 123.45\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
+9
View File
@@ -9,11 +9,20 @@ const (
CAMT_INDICATOR_DEBIT camtCreditDebitIndicator = "DBIT"
)
type camt052File struct {
XMLName xml.Name `xml:"Document"`
BankToCustomerAccountReport *camtBankToCustomerAccountReport `xml:"BkToCstmrAcctRpt"`
}
type camt053File struct {
XMLName xml.Name `xml:"Document"`
BankToCustomerStatement *camtBankToCustomerStatement `xml:"BkToCstmrStmt"`
}
type camtBankToCustomerAccountReport struct {
Statements []*camtStatement `xml:"Rpt"`
}
type camtBankToCustomerStatement struct {
Statements []*camtStatement `xml:"Stmt"`
}
+32
View File
@@ -10,11 +10,30 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
// camt052FileReader defines the structure of camt.052 file reader
type camt052FileReader struct {
xmlDecoder *xml.Decoder
}
// camt053FileReader defines the structure of camt.053 file reader
type camt053FileReader struct {
xmlDecoder *xml.Decoder
}
// read returns the imported camt.052 data
// Reference: https://www.iso20022.org/message-set/1196/download
func (r *camt052FileReader) read(ctx core.Context) (*camt052File, error) {
file := &camt052File{}
err := r.xmlDecoder.Decode(&file)
if err != nil {
return nil, err
}
return file, nil
}
// 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) {
@@ -29,6 +48,19 @@ func (r *camt053FileReader) read(ctx core.Context) (*camt053File, error) {
return file, nil
}
func createNewCamt052FileReader(data []byte) (*camt052FileReader, 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 &camt052FileReader{
xmlDecoder: xmlDecoder,
}, nil
}
return nil, errs.ErrInvalidXmlFile
}
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))
@@ -231,7 +231,7 @@ func (t *camtStatementTransactionDataRowIterator) parseTransaction(ctx core.Cont
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Unix(), 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
@@ -303,12 +303,12 @@ func (t *camtStatementTransactionDataRowIterator) parseTransaction(ctx core.Cont
return data, nil
}
func createNewCamtStatementTransactionDataTable(file *camt053File) (*camtStatementTransactionDataTable, error) {
if file == nil || file.BankToCustomerStatement == nil || len(file.BankToCustomerStatement.Statements) == 0 {
func createNewCamtStatementTransactionDataTable(camtStatements []*camtStatement) (*camtStatementTransactionDataTable, error) {
if len(camtStatements) == 0 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
return &camtStatementTransactionDataTable{
allStatements: file.BankToCustomerStatement.Statements,
allStatements: camtStatements,
}, nil
}
@@ -1,8 +1,11 @@
package camt
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -13,17 +16,51 @@ var camtTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
}
// camt052TransactionDataImporter defines the structure of camt.052 file importer for transaction data
type camt052TransactionDataImporter struct {
}
// camt053TransactionDataImporter defines the structure of camt.053 file importer for transaction data
type camt053TransactionDataImporter struct {
}
// Initialize a camt.053 transaction data importer singleton instance
// Initialize camt.052 and camt.053 transaction data importer singleton instances
var (
Camt052TransactionDataImporter = &camt052TransactionDataImporter{}
Camt053TransactionDataImporter = &camt053TransactionDataImporter{}
)
// ParseImportedData returns the imported data by parsing the camt.052 file transaction data
func (c *camt052TransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, 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) {
camt052DataReader, err := createNewCamt052FileReader(data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
camt052Data, err := camt052DataReader.read(ctx)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
if camt052Data.BankToCustomerAccountReport == nil || camt052Data.BankToCustomerAccountReport.Statements == nil {
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
}
transactionDataTable, err := createNewCamtStatementTransactionDataTable(camt052Data.BankToCustomerAccountReport.Statements)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(camtTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
// 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) {
func (c *camt053TransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, 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 {
@@ -36,7 +73,11 @@ func (c *camt053TransactionDataImporter) ParseImportedData(ctx core.Context, use
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable, err := createNewCamtStatementTransactionDataTable(camt053Data)
if camt053Data.BankToCustomerStatement == nil || camt053Data.BankToCustomerStatement.Statements == nil {
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
}
transactionDataTable, err := createNewCamtStatementTransactionDataTable(camt053Data.BankToCustomerStatement.Statements)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
@@ -44,5 +85,5 @@ func (c *camt053TransactionDataImporter) ParseImportedData(ctx core.Context, use
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(camtTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -2,17 +2,19 @@ package camt
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"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
func TestCamt052TransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
importer := Camt052TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -20,7 +22,110 @@ func TestCamt053TransactionDataFileParseImportedData_MinimumValidData(t *testing
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := importer.ParseImportedData(context, user, []byte(
`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.02">
<BkToCstmrAcctRpt>
<Rpt>
<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>
</Rpt>
<Rpt>
<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>
</Rpt>
</BkToCstmrAcctRpt>
</Document>`), time.UTC, converter.DefaultImporterOptions, 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_MinimumValidData(t *testing.T) {
importer := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := importer.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>
@@ -64,7 +169,7 @@ func TestCamt053TransactionDataFileParseImportedData_MinimumValidData(t *testing
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
@@ -115,7 +220,7 @@ func TestCamt053TransactionDataFileParseImportedData_MinimumValidData(t *testing
}
func TestCamt053TransactionDataFileParseImportedData_ParseValidTransactionTime(t *testing.T) {
converter := Camt053TransactionDataImporter
importer := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -123,7 +228,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseValidTransactionTime(t
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
allNewTransactions, _, _, _, _, _, err := importer.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>
@@ -157,7 +262,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseValidTransactionTime(t
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 3, len(allNewTransactions))
@@ -168,7 +273,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseValidTransactionTime(t
}
func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime(t *testing.T) {
converter := Camt053TransactionDataImporter
importer := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -176,7 +281,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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>
@@ -196,10 +301,10 @@ func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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>
@@ -219,10 +324,10 @@ func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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>
@@ -242,10 +347,10 @@ func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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>
@@ -265,12 +370,12 @@ func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmountAndCurrency(t *testing.T) {
converter := Camt053TransactionDataImporter
importer := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -278,7 +383,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmount
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
allNewTransactions, _, _, _, _, _, err := importer.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>
@@ -314,7 +419,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmount
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
@@ -323,7 +428,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmount
assert.Equal(t, "USD", allNewTransactions[1].OriginalSourceAccountCurrency)
assert.Equal(t, int64(10023), allNewTransactions[1].Amount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
allNewTransactions, _, _, _, _, _, err = importer.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>
@@ -365,7 +470,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmount
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
@@ -374,7 +479,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmount
assert.Equal(t, "USD", allNewTransactions[1].OriginalSourceAccountCurrency)
assert.Equal(t, int64(9999), allNewTransactions[1].Amount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
allNewTransactions, _, _, _, _, _, err = importer.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>
@@ -403,14 +508,14 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmount
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, 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(
allNewTransactions, _, _, _, _, _, err = importer.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>
@@ -430,7 +535,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmount
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -439,7 +544,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmount
}
func TestCamt053TransactionDataFileParseImportedData_ParseTransactionInvalidAmountAndCurrency(t *testing.T) {
converter := Camt053TransactionDataImporter
importer := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -447,7 +552,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionInvalidAmou
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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>
@@ -467,10 +572,10 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionInvalidAmou
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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>
@@ -498,10 +603,10 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionInvalidAmou
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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>
@@ -529,12 +634,12 @@ func TestCamt053TransactionDataFileParseImportedData_ParseTransactionInvalidAmou
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing.T) {
converter := Camt053TransactionDataImporter
importer := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -542,7 +647,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
allNewTransactions, _, _, _, _, _, err := importer.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>
@@ -572,13 +677,13 @@ func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, 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(
allNewTransactions, _, _, _, _, _, err = importer.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>
@@ -607,13 +712,13 @@ func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, 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(
allNewTransactions, _, _, _, _, _, err = importer.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>
@@ -634,7 +739,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
@@ -642,7 +747,7 @@ func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing
}
func TestCamt053TransactionDataFileParseImportedData_MissingAccountNode(t *testing.T) {
converter := Camt053TransactionDataImporter
importer := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -650,7 +755,7 @@ func TestCamt053TransactionDataFileParseImportedData_MissingAccountNode(t *testi
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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>
@@ -664,12 +769,12 @@ func TestCamt053TransactionDataFileParseImportedData_MissingAccountNode(t *testi
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingAccountData.Message)
}
func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredNode(t *testing.T) {
converter := Camt053TransactionDataImporter
importer := Camt053TransactionDataImporter
context := core.NewNullContext()
user := &models.User{
@@ -677,7 +782,7 @@ func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredN
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err := importer.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>
@@ -694,10 +799,10 @@ func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredN
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingTransactionTime.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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>
@@ -716,10 +821,10 @@ func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredN
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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>
@@ -738,10 +843,10 @@ func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredN
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
_, _, _, _, _, _, err = importer.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>
@@ -760,6 +865,6 @@ func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredN
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`), 0, nil, nil, nil, nil, nil)
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
@@ -28,10 +28,11 @@ func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context
}
dataRowMap := make(map[datatable.TransactionDataTableColumn]string, 15)
transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)
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_TIME] = utils.FormatUnixTimeToLongDateTime(transactionUnixTime, transactionTimeZone)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(transactionUnixTime, 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)
@@ -3,6 +3,7 @@ package converter
import (
"sort"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
@@ -29,7 +30,7 @@ type DataTableTransactionDataImporter struct {
}
// 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) {
func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, dataTable datatable.TransactionDataTable, defaultTimezone *time.Location, additionalOptions TransactionDataImporterOptions, 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
@@ -94,7 +95,7 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
continue
}
timezoneOffset := defaultTimezoneOffset
timezone := defaultTimezone
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) &&
dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) != datatable.TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE {
@@ -105,10 +106,10 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
return nil, nil, nil, nil, nil, nil, errs.ErrTransactionTimeZoneInvalid
}
timezoneOffset = utils.GetTimezoneOffsetMinutes(transactionTimezone)
timezone = transactionTimezone
}
transactionTime, err := utils.ParseFromLongDateTime(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME), timezoneOffset)
transactionTime, err := utils.ParseFromLongDateTimeInTimeZone(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME), timezone)
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())
@@ -303,6 +304,7 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
var tagIds []string
var tagNames []string
tagNamesMap := make(map[string]bool)
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TAGS) {
var tagNameItems []string
@@ -320,19 +322,39 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
continue
}
tag, exists := tagMap[tagName]
allNewTags, tagIds, tagNames = c.addTag(user, tagName, tagNamesMap, tagMap, allNewTags, tagIds, tagNames)
}
}
if !exists {
tag = c.createNewTransactionTagModel(user.Uid, tagName)
allNewTags = append(allNewTags, tag)
tagMap[tagName] = tag
}
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_PAYEE) && additionalOptions.IsPayeeAsTag() {
payee := dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_PAYEE)
if tag != nil {
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
}
if payee != "" {
allNewTags, tagIds, tagNames = c.addTag(user, payee, tagNamesMap, tagMap, allNewTags, tagIds, tagNames)
}
}
tagNames = append(tagNames, tagName)
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_MEMBER) && additionalOptions.IsMemberAsTag() {
member := dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_MEMBER)
if member != "" {
allNewTags, tagIds, tagNames = c.addTag(user, member, tagNamesMap, tagMap, allNewTags, tagIds, tagNames)
}
}
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_PROJECT) && additionalOptions.IsProjectAsTag() {
project := dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_PROJECT)
if project != "" {
allNewTags, tagIds, tagNames = c.addTag(user, project, tagNamesMap, tagMap, allNewTags, tagIds, tagNames)
}
}
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_MERCHANT) && additionalOptions.IsMerchantAsTag() {
merchant := dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_MERCHANT)
if merchant != "" {
allNewTags, tagIds, tagNames = c.addTag(user, merchant, tagNamesMap, tagMap, allNewTags, tagIds, tagNames)
}
}
@@ -342,13 +364,17 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
description = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_DESCRIPTION)
}
if description == "" && additionalOptions.IsPayeeAsDescription() && dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_PAYEE) {
description = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_PAYEE)
}
transaction := &models.ImportTransaction{
Transaction: &models.Transaction{
Uid: user.Uid,
Type: transactionDbType,
CategoryId: categoryId,
TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()),
TimezoneUtcOffset: timezoneOffset,
TimezoneUtcOffset: utils.GetTimezoneOffsetMinutes(transactionTime.Unix(), timezone),
AccountId: account.AccountId,
Amount: amount,
HideAmount: false,
@@ -459,6 +485,27 @@ func (c *DataTableTransactionDataImporter) getTransactionCategory(categories map
return subCategory, exists
}
func (c *DataTableTransactionDataImporter) addTag(user *models.User, tagName string, tagNamesMap map[string]bool, tagMap map[string]*models.TransactionTag, allNewTags []*models.TransactionTag, tagIds []string, tagNames []string) ([]*models.TransactionTag, []string, []string) {
if tagName != "" && !tagNamesMap[tagName] {
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)
tagNamesMap[tagName] = true
}
return allNewTags, tagIds, tagNames
}
func (c *DataTableTransactionDataImporter) createNewAccountModel(uid int64, accountName string, currency string) *models.Account {
return &models.Account{
Uid: uid,
@@ -1,6 +1,8 @@
package converter
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
@@ -14,7 +16,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]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)
ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions TransactionDataImporterOptions, 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,118 @@
package converter
import "strings"
// TransactionDataImporterOptions defines the options for transaction data importer
type TransactionDataImporterOptions struct {
payeeAsTag bool
payeeAsDescription bool
memberAsTag bool
projectAsTag bool
merchantAsTag bool
}
// DefaultImporterOptions provides the default options for transaction data importer
var DefaultImporterOptions = TransactionDataImporterOptions{
payeeAsTag: false,
payeeAsDescription: false,
memberAsTag: false,
projectAsTag: false,
merchantAsTag: false,
}
// IsPayeeAsTag returns whether to import payee as tag
func (o TransactionDataImporterOptions) IsPayeeAsTag() bool {
return o.payeeAsTag
}
// IsPayeeAsDescription returns whether to import payee as description
func (o TransactionDataImporterOptions) IsPayeeAsDescription() bool {
return o.payeeAsDescription
}
// IsMemberAsTag returns whether to import member as tag
func (o TransactionDataImporterOptions) IsMemberAsTag() bool {
return o.memberAsTag
}
// IsProjectAsTag returns whether to import project as tag
func (o TransactionDataImporterOptions) IsProjectAsTag() bool {
return o.projectAsTag
}
// IsMerchantAsTag returns whether to import merchant as tag
func (o TransactionDataImporterOptions) IsMerchantAsTag() bool {
return o.merchantAsTag
}
// WithPayeeAsTag sets the option to import payee as tag
func (o TransactionDataImporterOptions) WithPayeeAsTag() TransactionDataImporterOptions {
cloned := o.Clone()
cloned.payeeAsTag = true
return cloned
}
// WithPayeeAsDescription sets the option to import payee as description
func (o TransactionDataImporterOptions) WithPayeeAsDescription() TransactionDataImporterOptions {
cloned := o.Clone()
cloned.payeeAsDescription = true
return cloned
}
// WithMemberAsTag sets the option to import member as tag
func (o TransactionDataImporterOptions) WithMemberAsTag() TransactionDataImporterOptions {
cloned := o.Clone()
cloned.memberAsTag = true
return cloned
}
// WithProjectAsTag sets the option to import project as tag
func (o TransactionDataImporterOptions) WithProjectAsTag() TransactionDataImporterOptions {
cloned := o.Clone()
cloned.projectAsTag = true
return cloned
}
// WithMerchantAsTag sets the option to import merchant as tag
func (o TransactionDataImporterOptions) WithMerchantAsTag() TransactionDataImporterOptions {
cloned := o.Clone()
cloned.merchantAsTag = true
return cloned
}
// Clone creates a copy of the options instance
func (o TransactionDataImporterOptions) Clone() TransactionDataImporterOptions {
return TransactionDataImporterOptions{
payeeAsTag: o.payeeAsTag,
payeeAsDescription: o.payeeAsDescription,
memberAsTag: o.memberAsTag,
projectAsTag: o.projectAsTag,
merchantAsTag: o.merchantAsTag,
}
}
// ParseImporterOptions parses the textual options to the instance
func ParseImporterOptions(s string) TransactionDataImporterOptions {
options := TransactionDataImporterOptions{}
if s == "" {
return options
}
for _, option := range strings.Split(s, ",") {
switch option {
case "payeeAsTag":
options.payeeAsTag = true
case "payeeAsDescription":
options.payeeAsDescription = true
case "memberAsTag":
options.memberAsTag = true
case "projectAsTag":
options.projectAsTag = true
case "merchantAsTag":
options.merchantAsTag = true
}
}
return options
}
@@ -0,0 +1,110 @@
package converter
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseImporterOptions(t *testing.T) {
actualValue := ParseImporterOptions("payeeAsTag,memberAsTag")
expectedValue := TransactionDataImporterOptions{
payeeAsTag: true,
memberAsTag: true,
projectAsTag: false,
merchantAsTag: false,
}
assert.Equal(t, expectedValue, actualValue)
assert.Equal(t, true, actualValue.IsPayeeAsTag())
assert.Equal(t, true, actualValue.IsMemberAsTag())
assert.Equal(t, false, actualValue.IsProjectAsTag())
assert.Equal(t, false, actualValue.IsMerchantAsTag())
actualValue = ParseImporterOptions("")
expectedValue = TransactionDataImporterOptions{
payeeAsTag: false,
memberAsTag: false,
projectAsTag: false,
merchantAsTag: false,
}
assert.Equal(t, expectedValue, actualValue)
assert.Equal(t, false, actualValue.IsPayeeAsTag())
assert.Equal(t, false, actualValue.IsMemberAsTag())
assert.Equal(t, false, actualValue.IsProjectAsTag())
assert.Equal(t, false, actualValue.IsMerchantAsTag())
}
func TestParseImporterOptions_WithAllOptions(t *testing.T) {
actualValue := ParseImporterOptions("payeeAsTag,payeeAsDescription,memberAsTag,projectAsTag,merchantAsTag")
expectedValue := TransactionDataImporterOptions{
payeeAsTag: true,
payeeAsDescription: true,
memberAsTag: true,
projectAsTag: true,
merchantAsTag: true,
}
assert.Equal(t, expectedValue, actualValue)
assert.Equal(t, true, actualValue.IsPayeeAsTag())
assert.Equal(t, true, actualValue.IsPayeeAsDescription())
assert.Equal(t, true, actualValue.IsMemberAsTag())
assert.Equal(t, true, actualValue.IsProjectAsTag())
assert.Equal(t, true, actualValue.IsMerchantAsTag())
}
func TestParseImporterOptions_WithInvalidOptions(t *testing.T) {
actualValue := ParseImporterOptions("invalidOption,payeeAsTag,memberAsTag")
expectedValue := TransactionDataImporterOptions{
payeeAsTag: true,
memberAsTag: true,
projectAsTag: false,
merchantAsTag: false,
}
assert.Equal(t, expectedValue, actualValue)
assert.Equal(t, true, actualValue.IsPayeeAsTag())
assert.Equal(t, true, actualValue.IsMemberAsTag())
assert.Equal(t, false, actualValue.IsProjectAsTag())
assert.Equal(t, false, actualValue.IsMerchantAsTag())
actualValue = ParseImporterOptions("invalidOption")
expectedValue = TransactionDataImporterOptions{
payeeAsTag: false,
memberAsTag: false,
projectAsTag: false,
merchantAsTag: false,
}
assert.Equal(t, expectedValue, actualValue)
assert.Equal(t, false, actualValue.IsPayeeAsTag())
assert.Equal(t, false, actualValue.IsMemberAsTag())
assert.Equal(t, false, actualValue.IsProjectAsTag())
assert.Equal(t, false, actualValue.IsMerchantAsTag())
}
func TestParseImporterOptions_Clone(t *testing.T) {
original := TransactionDataImporterOptions{
payeeAsTag: true,
payeeAsDescription: false,
memberAsTag: false,
projectAsTag: true,
merchantAsTag: false,
}
cloned := original.Clone()
assert.Equal(t, original, cloned)
// Modify cloned options and verify original options are not affected
cloned.payeeAsTag = false
cloned.payeeAsDescription = true
cloned.memberAsTag = true
assert.Equal(t, true, original.payeeAsTag)
assert.Equal(t, false, original.payeeAsDescription)
assert.Equal(t, false, original.memberAsTag)
assert.Equal(t, true, original.projectAsTag)
assert.Equal(t, false, original.merchantAsTag)
assert.Equal(t, false, cloned.payeeAsTag)
assert.Equal(t, true, cloned.payeeAsDescription)
assert.Equal(t, true, cloned.memberAsTag)
assert.Equal(t, true, cloned.projectAsTag)
assert.Equal(t, false, cloned.merchantAsTag)
}
+27 -10
View File
@@ -13,7 +13,8 @@ import (
// CsvFileBasicDataTable defines the structure of csv data table
type CsvFileBasicDataTable struct {
allLines [][]string
allLines [][]string
hasTitleLine bool
}
// CsvFileBasicDataTableRow defines the structure of csv data table row
@@ -34,7 +35,11 @@ func (t *CsvFileBasicDataTable) DataRowCount() int {
return 0
}
return len(t.allLines) - 1
if t.hasTitleLine {
return len(t.allLines) - 1
} else {
return len(t.allLines)
}
}
// HeaderColumnNames returns the header column name list
@@ -43,14 +48,24 @@ func (t *CsvFileBasicDataTable) HeaderColumnNames() []string {
return nil
}
return t.allLines[0]
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: 0,
currentIndex: startIndex,
}
}
@@ -95,18 +110,19 @@ func (t *CsvFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
}
// CreateNewCsvBasicDataTable returns comma separated values data table by io readers
func CreateNewCsvBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, error) {
return createNewCsvFileBasicDataTable(ctx, reader, ',')
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) datatable.BasicDataTable {
func CreateNewCustomCsvBasicDataTable(allLines [][]string, hasTitleLine bool) datatable.BasicDataTable {
return &CsvFileBasicDataTable{
allLines: allLines,
allLines: allLines,
hasTitleLine: hasTitleLine,
}
}
func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune) (*CsvFileBasicDataTable, error) {
func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune, hasTitleLine bool) (*CsvFileBasicDataTable, error) {
csvReader := csv.NewReader(reader)
csvReader.Comma = separator
csvReader.FieldsPerRecord = -1
@@ -133,6 +149,7 @@ func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separato
}
return &CsvFileBasicDataTable{
allLines: allLines,
allLines: allLines,
hasTitleLine: hasTitleLine,
}, nil
}
@@ -14,7 +14,17 @@ func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
{"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())
}
@@ -22,14 +32,16 @@ func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
func TestCsvFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
})
}, true)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestCsvFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{})
datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
assert.Equal(t, 0, datatable.DataRowCount())
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
assert.Equal(t, 0, datatable.DataRowCount())
}
@@ -38,14 +50,16 @@ func TestCsvFileBasicDataTableHeaderColumnNames(t *testing.T) {
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, true)
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
}
func TestCsvFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{})
datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
assert.Nil(t, datatable.HeaderColumnNames())
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
assert.Nil(t, datatable.HeaderColumnNames())
}
@@ -54,7 +68,34 @@ func TestCsvFileBasicDataTableRowIterator(t *testing.T) {
{"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())
@@ -81,7 +122,7 @@ func TestCsvFileBasicDataTableRowColumnCount(t *testing.T) {
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, true)
iterator := datatable.DataRowIterator()
@@ -97,7 +138,32 @@ func TestCsvFileBasicDataTableRowGetData(t *testing.T) {
{"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()
@@ -117,7 +183,7 @@ func TestCsvFileBasicDataTableRowGetData_GetNotExistedColumnData(t *testing.T) {
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
}, true)
iterator := datatable.DataRowIterator()
@@ -130,7 +196,7 @@ func TestCreateNewCsvBasicDataTable(t *testing.T) {
reader := bytes.NewReader([]byte("A1,B1,C1\n" +
"A2,B2,C2\n" +
"A3,B3,C3\n"))
datatable, err := CreateNewCsvBasicDataTable(context, reader)
datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
@@ -160,7 +226,7 @@ func TestCreateNewCsvBasicDataTable_SkipBlankLine(t *testing.T) {
"A2,B2,C2\n" +
"\n" +
"A3,B3,C3\n"))
datatable, err := CreateNewCsvBasicDataTable(context, reader)
datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
@@ -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,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())
}
@@ -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))
}
@@ -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)
}
@@ -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())
}
@@ -72,6 +72,10 @@ const (
TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION TransactionDataTableColumn = 12
TRANSACTION_DATA_TABLE_TAGS TransactionDataTableColumn = 13
TRANSACTION_DATA_TABLE_DESCRIPTION TransactionDataTableColumn = 14
TRANSACTION_DATA_TABLE_PAYEE TransactionDataTableColumn = 101
TRANSACTION_DATA_TABLE_MEMBER TransactionDataTableColumn = 102
TRANSACTION_DATA_TABLE_PROJECT TransactionDataTableColumn = 103
TRANSACTION_DATA_TABLE_MERCHANT TransactionDataTableColumn = 104
)
// TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE represents the constant for timezone not available

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