Compare commits
870 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 422f18443a | |||
| 0fbf185223 | |||
| 91cdffa9a6 | |||
| 89199eed8b | |||
| 1a65bb9db6 | |||
| 9772d9ca62 | |||
| 5ee93a5db1 | |||
| 85c4f686da | |||
| 1f066b0d1e | |||
| 38ddb7aaa3 | |||
| a22931f96b | |||
| dcee067aea | |||
| 302d118ae0 | |||
| 09eea96cdc | |||
| 205dea9e58 | |||
| 089eabb806 | |||
| dd63500202 | |||
| 13488efdaf | |||
| edcf33f49c | |||
| d601e01029 | |||
| 4d7c3650b5 | |||
| a0fd468309 | |||
| 0b7471879d | |||
| 282b74c95e | |||
| 5ce1dc973c | |||
| 7ac1e0b69f | |||
| 127bed1026 | |||
| d517a1862b | |||
| 8e5202b375 | |||
| 301fb58917 | |||
| aedebb1461 | |||
| 1336377598 | |||
| 3b58dcbc4d | |||
| 23a5f0a96f | |||
| b81d2ec63c | |||
| cabe365907 | |||
| 54f61ecb18 | |||
| 404cd62d7b | |||
| f0f3143605 | |||
| b729fdedca | |||
| 973cec2c6a | |||
| 6e61aba050 | |||
| 40a8deba12 | |||
| 0ba762ba6e | |||
| 732c256db2 | |||
| d2ce801277 | |||
| 4845fdedfd | |||
| f5a7e2e2d6 | |||
| a84f48ae8a | |||
| c4c9503e31 | |||
| 8c1f499ed8 | |||
| c6eb3cfb74 | |||
| d7a0d253c4 | |||
| 9d275a3051 | |||
| 8192a48bc5 | |||
| 247181830c | |||
| d5dfdc8c05 | |||
| d95fcd8b00 | |||
| 40a366e68d | |||
| 593ae10783 | |||
| 75d9e11bab | |||
| 6d37d42e50 | |||
| f9e9c9285f | |||
| 314bf876f2 | |||
| 61c52cc888 | |||
| b42f226aba | |||
| 767b841866 | |||
| fd08666f49 | |||
| eb662681a1 | |||
| ef15eccc33 | |||
| e0286ff133 | |||
| 2baffe3f11 | |||
| 196657ee86 | |||
| b4c4aafc99 | |||
| b907a79223 | |||
| 0d213de580 | |||
| 2e97d699e7 | |||
| 22e4738b7a | |||
| 4b68641043 | |||
| 3a66a3d655 | |||
| 76d1d3aef3 | |||
| fe2aa5d28b | |||
| f474bbf09a | |||
| c4d02db879 | |||
| 75b36ec547 | |||
| 43b7aea76e | |||
| 13a4a47d40 | |||
| fd9f380922 | |||
| a5fdb9d6b7 | |||
| 983c65e4f8 | |||
| fa568056d3 | |||
| ea8b2812d4 | |||
| b6a2aea8fd | |||
| fa047bf303 | |||
| 4177ac3d46 | |||
| 7647f4f5b9 | |||
| bab03dbde1 | |||
| 85db6e96af | |||
| 548461ade0 | |||
| ecbf182173 | |||
| ab38d33e31 | |||
| 0020f4ede9 | |||
| b470cb63b7 | |||
| 32f2eaef3c | |||
| a7fc3c78eb | |||
| d42b3ecb5e | |||
| 2d4a603d11 | |||
| 7a369328b6 | |||
| 545667e502 | |||
| 8387a81a59 | |||
| 1e3087ccf0 | |||
| bee7772bfd | |||
| a8b6f72ee6 | |||
| 9484cf514d | |||
| 70958c00d3 | |||
| 9467335536 | |||
| f916fdff06 | |||
| ced346506e | |||
| e0cd96f87e | |||
| ed7e906903 | |||
| 3bb7f5abf4 | |||
| 5d801a2343 | |||
| 0d9e59dad9 | |||
| 5fd1396b5c | |||
| b3b9d9293b | |||
| 8b405e513f | |||
| 2bfcfbf03d | |||
| 10388a5ffa | |||
| a127a381cc | |||
| 4aa0dc20af | |||
| 012cc04107 | |||
| 25a84ad3af | |||
| c0036d230a | |||
| 869970a4ab | |||
| 42f8aa410c | |||
| 80e1223505 | |||
| fc9581580c | |||
| b0e6764bfe | |||
| 03fef81414 | |||
| 8dcb8648a5 | |||
| 50b4c96a99 | |||
| c9b894fdbe | |||
| a2f1d944ad | |||
| be4ec2bcce | |||
| c1a728c391 | |||
| 46ff0ecd3b | |||
| 8db69f64c8 | |||
| 8447dd7ae6 | |||
| 543cc5f656 | |||
| 9664bac47f | |||
| 42ae323568 | |||
| a357fb8136 | |||
| 3b487ca0d9 | |||
| 91e98f3126 | |||
| 7ecacaeb05 | |||
| 598ae9fa06 | |||
| 0803a5930f | |||
| 9aaf3284c0 | |||
| b27f9c12de | |||
| a730ebab8f | |||
| f7d0e2279a | |||
| e41dd1c1f8 | |||
| 98274ab864 | |||
| a3261acc82 | |||
| 7d9cfc4ced | |||
| b556efa510 | |||
| 4b72bfd76d | |||
| 0f532094ca | |||
| 7e48cca4ab | |||
| 98aa535193 | |||
| 48ef9acc19 | |||
| e304f4d3fa | |||
| 83a34ae322 | |||
| 43a6d1be0f | |||
| 89fb8a099e | |||
| 853b0d430e | |||
| 88b63d0222 | |||
| 618ad4cac2 | |||
| 9b4dd5600a | |||
| ca959fb9ce | |||
| ee9b281919 | |||
| 1a0630846d | |||
| 9585cbc8a9 | |||
| 19c0ca8191 | |||
| 3b0b95ac4a | |||
| 1691c320cc | |||
| caf88a9488 | |||
| b295b99d3d | |||
| 3cf1276fa7 | |||
| 5ae763273a | |||
| e39965e7b5 | |||
| af36fe9212 | |||
| 6eb7fa27f6 | |||
| 0dd0597c3b | |||
| f0a74a6108 | |||
| 6829eddde5 | |||
| 1c596c4a15 | |||
| ab88b0bf44 | |||
| d462d0164c | |||
| d4d1342c70 | |||
| a157c1961a | |||
| 9a037ace5a | |||
| c64b4502cb | |||
| dc41bf8e10 | |||
| 0ce66d9070 | |||
| 6e369f39a4 | |||
| fb25f589fb | |||
| 8651755d7a | |||
| 277da30339 | |||
| 2fb509beb2 | |||
| 6634d5b791 | |||
| 41739d97e7 | |||
| 43bc04012d | |||
| 43154832b6 | |||
| 91a00cb5b3 | |||
| 526d7e50ec | |||
| cc0996e0d2 | |||
| 8be5e8aa1d | |||
| 022dd3303b | |||
| 2865635013 | |||
| c276f261f9 | |||
| ee7e98bb00 | |||
| 554ce37475 | |||
| 1938d972ff | |||
| 630859bc25 | |||
| 8ea8a9fe2a | |||
| f5e4d82efc | |||
| 958515b9e0 | |||
| 5131e3d6e3 | |||
| b5a18c86dc | |||
| 2f3e26dbe5 | |||
| 3313ccf051 | |||
| 2ada077b38 | |||
| 31c36f0edf | |||
| e74d290016 | |||
| 28337ae228 | |||
| e252378898 | |||
| 1cc0cd7ae6 | |||
| 088e9a339d | |||
| b009c7b6e5 | |||
| 6bb69b0c27 | |||
| 842683da25 | |||
| d39816bb9f | |||
| e856aefd7b | |||
| f54c4998ef | |||
| 59a138d417 | |||
| 0dc2825e5d | |||
| 76af5d946a | |||
| c35cbbda15 | |||
| ece58b60ec | |||
| d95e34a597 | |||
| a09d7b57f9 | |||
| a535fbcef1 | |||
| 931d5e8395 | |||
| b37450db15 | |||
| 2dd7fd30de | |||
| fb55cd1b33 | |||
| e9b4392163 | |||
| 861e4c036b | |||
| e825323bb0 | |||
| aebd65449b | |||
| 0a8f62741a | |||
| b1cefa5a34 | |||
| a12038e40c | |||
| b2fab42170 | |||
| e9c3001c28 | |||
| 44039438e0 | |||
| 372ea29edd | |||
| 1eb958a21b | |||
| 89dd306bb4 | |||
| c170cb42e6 | |||
| 78f3beaf2f | |||
| 482d025c90 | |||
| 11c943efef | |||
| 6debea6dbb | |||
| a7a8b9a2fb | |||
| 47cc046e60 | |||
| 5a9f4ec3b4 | |||
| 70aa19c623 | |||
| d2771f6fa9 | |||
| 5e4637c6ad | |||
| a9a7d28082 | |||
| ffce01c612 | |||
| 7a3ec9468f | |||
| 5850e0e298 | |||
| fdf6548cc9 | |||
| ee8aa2bb8e | |||
| eccea273e6 | |||
| e143c8f098 | |||
| 81226c3bb2 | |||
| c3db8cee2d | |||
| 9061fc3188 | |||
| b9539c4aba | |||
| deabe178df | |||
| a5320cf929 | |||
| 79842d9171 | |||
| 3daff44155 | |||
| cd2cce4268 | |||
| ad132d5637 | |||
| c8b3daa915 | |||
| 96561ec2be | |||
| 608411feab | |||
| 516e3a5613 | |||
| 2431152cec | |||
| 17f604b6aa | |||
| 3fe51dce63 | |||
| 2c454f001e | |||
| 4781cb34eb | |||
| 9faea14e36 | |||
| bd704a8c15 | |||
| bb9a19bcb2 | |||
| 9ff1334584 | |||
| de27c8e6c5 | |||
| ba278e47ff | |||
| 7d70859107 | |||
| 6430a52027 | |||
| 45be96cf68 | |||
| 837a62a534 | |||
| 707283fd66 | |||
| 10b9c09192 | |||
| ed8c5c96ac | |||
| 3ba91c590e | |||
| 44dc45de51 | |||
| 83bd68e7f4 | |||
| dafbc115c4 | |||
| 5de1e32cd8 | |||
| 8ae5c1ea99 | |||
| 29651f674a | |||
| b1dff5ef51 | |||
| bb0971ea17 | |||
| 8a020b666c | |||
| 8b34750426 | |||
| 32cb2b2354 | |||
| 4c8bb5a0b7 | |||
| d3abb279e3 | |||
| 952731a2d4 | |||
| df23cb8cdd | |||
| 87a21a1a4f | |||
| 7c3c1bbd6a | |||
| 03c342f6f6 | |||
| b0e01d36ab | |||
| bb84e8af13 | |||
| f42ee9cf67 | |||
| 8a0232aedf | |||
| f3ccd3b66d | |||
| b690316aa7 | |||
| 8a0777be4c | |||
| 013f44f64a | |||
| 9f8dbf77df | |||
| 274fb8b4e2 | |||
| 48a06c6570 | |||
| 5485242baf | |||
| ec7c4c7461 | |||
| 2259719935 | |||
| f8fc955408 | |||
| 4684de9705 | |||
| 52bab6f726 | |||
| 765e64d96f | |||
| f93610b5e0 | |||
| 5d1480cabc | |||
| 5faf3bfe66 | |||
| 5cb7eca340 | |||
| 9a2f682379 | |||
| fd4036f0c8 | |||
| c854dbaab4 | |||
| 0c75ed47ac | |||
| fa467e72f9 | |||
| 93e05d5634 | |||
| 745efe1222 | |||
| e1dcf56ca9 | |||
| 3aa33a48e9 | |||
| 29547bccb1 | |||
| 4823760fd1 | |||
| 8584e84af9 | |||
| af586a0432 | |||
| ce752c992c | |||
| 7b49a9f142 | |||
| 2fc5e91cc4 | |||
| f6d03bf5df | |||
| a17a2cc377 | |||
| beea6fe733 | |||
| 85b05f9e7e | |||
| d3ab2b94b7 | |||
| b21fff5b15 | |||
| 234e7a55ff | |||
| d4cf8fe077 | |||
| 2b2a266533 | |||
| 4b35103e34 | |||
| 81a5585029 | |||
| d2b89e629a | |||
| cab86eec68 | |||
| 295f5cc14a | |||
| 6395e3b5c1 | |||
| a42c5fa988 | |||
| 46e275d843 | |||
| 13ada3575a | |||
| 3b0e0f1a3f | |||
| 512acc5a49 | |||
| 1f101fea3e | |||
| 83bd8f23f4 | |||
| af56c3057c | |||
| 53a8ad71c6 | |||
| 600ae2bd58 | |||
| 60b6ed51cd | |||
| 8a947ef224 | |||
| d936b64cf9 | |||
| ab828ebdab | |||
| b444e8ee31 | |||
| 45f1177a73 | |||
| 64e7dc5e12 | |||
| e62bebb7fa | |||
| 3990a072ca | |||
| a5bd12945d | |||
| 7938e7c7c8 | |||
| 130a157abc | |||
| cce19ae957 | |||
| e90340fec4 | |||
| 22061e535a | |||
| 23a85d6162 | |||
| 2cb47bfd75 | |||
| 3ce7f6e99a | |||
| 77b083c41b | |||
| b8fcdacb84 | |||
| d893193e73 | |||
| 73f8446d07 | |||
| 5692bec216 | |||
| e88491268b | |||
| 94cd5dc21a | |||
| 697f69d5d7 | |||
| 76fff27b3f | |||
| 4f664dbfc3 | |||
| d9b726cdf9 | |||
| cc792b9c0f | |||
| b916217b28 | |||
| 6c4b7059ed | |||
| 9d0e294ed2 | |||
| dc6d8398b1 | |||
| a50d2e7e72 | |||
| ea5cfe60f2 | |||
| 5b37ea4d78 | |||
| 46dd2888a6 | |||
| 564c9e1d95 | |||
| 4d0d3959a9 | |||
| e9e6644e7f | |||
| 6a19131ea1 | |||
| 4d9643dcb2 | |||
| 5dc4ad60ba | |||
| 2e5dd7d513 | |||
| efe088f591 | |||
| d5016e853e | |||
| d334bd7b9a | |||
| 21edf0157a | |||
| 388167705a | |||
| 2423b37cbb | |||
| 786796d457 | |||
| 0ed9216260 | |||
| eb13f10121 | |||
| 76ce6f6f9c | |||
| eb305139f5 | |||
| 8df73f202a | |||
| c22751de6f | |||
| c3f1cb0c61 | |||
| fc1fc58aa1 | |||
| e4b5e96534 | |||
| 66303a8965 | |||
| 9589dd2486 | |||
| 3d5b887e23 | |||
| b967a214cb | |||
| 5a9877588f | |||
| fc5f8e4633 | |||
| 028bca50ea | |||
| 6853bbfb68 | |||
| d4fee27a3d | |||
| 245fdd78e4 | |||
| cbe784172e | |||
| bf21e45cba | |||
| 359c430a39 | |||
| 669a217180 | |||
| e9507241ed | |||
| f2536749f6 | |||
| 118558d25b | |||
| d9cd270ff4 | |||
| 9dee449f10 | |||
| ae19ca4383 | |||
| 32fed8d6fb | |||
| ec325c9e6b | |||
| 5fbb29abd3 | |||
| f06c6523a2 | |||
| 02514fc457 | |||
| 5d88287ae2 | |||
| 00f1d0418f | |||
| 18b270debb | |||
| d947164eb6 | |||
| 1a1bb6077c | |||
| 05b5cab12b | |||
| a82fdd4946 | |||
| 4def7ed60c | |||
| d50ce0140f | |||
| 51678aee04 | |||
| 019689087d | |||
| 0c1d77f7ae | |||
| 8de51e6e71 | |||
| dc993da218 | |||
| 983f7fec0f | |||
| ce74c4817b | |||
| bc363438f1 | |||
| 979b16d520 | |||
| 9686eb020f | |||
| 88dea9acaa | |||
| c75fdfea1c | |||
| 538d2b8205 | |||
| 30d36a3b07 | |||
| 95bcd8e4c8 | |||
| 1a8ce7d58d | |||
| 4700446ca0 | |||
| 67bc81d3e2 | |||
| 878a3a018e | |||
| e463c2dc95 | |||
| 422cf49517 | |||
| 77d2426c14 | |||
| 1c4dc55bb6 | |||
| ba72f421dc | |||
| 36d1e01008 | |||
| e52c7037c7 | |||
| f5235ba08e | |||
| adc4899ea6 | |||
| 34c5a1750e | |||
| c75a902d84 | |||
| 7e2e1a4ad3 | |||
| d4603a1892 | |||
| 642e51bc0c | |||
| 5591abdb3b | |||
| ce9378c43f | |||
| 3ae72623ad | |||
| affc02655b | |||
| a469d66358 | |||
| 757f9e5b02 | |||
| 8368b02be8 | |||
| e15a5617e6 | |||
| f604b2c766 | |||
| d6dc9f8170 | |||
| a71be1bf05 | |||
| bcf11631d6 | |||
| 989183c8be | |||
| 8bd0fd88af | |||
| 20e2444307 | |||
| 8154bd712b | |||
| 4d0e376568 | |||
| 32cf41a7a0 | |||
| e85a4701ed | |||
| b79ffafaee | |||
| 8f6adaa417 | |||
| 0e634d83f4 | |||
| af8cbe0b15 | |||
| 411130db4e | |||
| c099443783 | |||
| 23ffdbb163 | |||
| 0b48502a10 | |||
| 25681f622d | |||
| f196ce969b | |||
| 0408c470fc | |||
| 01aeb945ff | |||
| 601a1f83c6 | |||
| 2a470742e0 | |||
| 8ba1e1997f | |||
| 27ae401a7f | |||
| 81727d3b1e | |||
| 06a0501633 | |||
| 781c2d9044 | |||
| 15e4ad00ee | |||
| 8064a00252 | |||
| f2d0fe407b | |||
| 9589657fd5 | |||
| 790837076f | |||
| 6d923027a0 | |||
| 13d5759e84 | |||
| efe39c7390 | |||
| c00770201b | |||
| 4eff3a337f | |||
| 451385011e | |||
| cd4d230d29 | |||
| ab6d4ee6fc | |||
| 274aa6a17c | |||
| 2f8d4ad5e4 | |||
| fe59d3b280 | |||
| e2c99c4f04 | |||
| 127393b64a | |||
| f3d240442b | |||
| 55bf8b9e30 | |||
| eadcf7768f | |||
| 876bf8cc31 | |||
| 6b5aac0111 | |||
| dc4a4e1463 | |||
| 0677ed07db | |||
| ecf6fbd187 | |||
| 351cebe169 | |||
| 0f94a90882 | |||
| 04996d784f | |||
| aafcfeda84 | |||
| 7283b724b1 | |||
| 0d55912f6c | |||
| 60108e26c7 | |||
| be129cd3c6 | |||
| f210bfa9f4 | |||
| 263113a67f | |||
| 3b29303237 | |||
| 6e5f857e97 | |||
| 791c0ea26e | |||
| 84523d8b8a | |||
| d35e127b9e | |||
| ebe00d3271 | |||
| 14b4e40039 | |||
| 15d1d269ae | |||
| e90b76c80e | |||
| e28e27080a | |||
| 2268496dcb | |||
| 3781327c58 | |||
| 51c33d7e83 | |||
| 975a56e7d9 | |||
| 29a87dcfaf | |||
| cad53d0bfc | |||
| 56a3905df1 | |||
| 428a1f2156 | |||
| b5233399e6 | |||
| f8878c5405 | |||
| 8dcaa457f9 | |||
| b24ebdb83e | |||
| d41a2141a7 | |||
| 09a1dd0358 | |||
| 531c4a44d5 | |||
| ceecff8c24 | |||
| f32cc4ab04 | |||
| 8fa46281e0 | |||
| f7bc4b3ab6 | |||
| ad4f5bd88d | |||
| e4cb66718d | |||
| 175b272fa0 | |||
| ca0fb9446b | |||
| 6eb749dca2 | |||
| 880b614636 | |||
| d146a99c65 | |||
| fd99c784b3 | |||
| 22f9c5243a | |||
| 67f5aaa5ee | |||
| 713b621169 | |||
| 80df5f95aa | |||
| 1e492d8724 | |||
| 602f15fe2e | |||
| 3335533a18 | |||
| d385358aa3 | |||
| d6ee8a416f | |||
| c5aa37037f | |||
| 6050f5deab | |||
| 5d07d1a70d | |||
| bae330c6f3 | |||
| ea17994c6c | |||
| c3d29ee2f8 | |||
| 515b9af61a | |||
| 4ba3893b83 | |||
| bcb6c4f419 | |||
| 53f101fb60 | |||
| 8da4f65048 | |||
| 428bcba56e | |||
| 68e896d8eb | |||
| eef62722a4 | |||
| e3dcb2ce0c | |||
| 0cf89562cd | |||
| 8b06731cdb | |||
| 36abd1acec | |||
| 06ef2220d6 | |||
| 29d14bb5ef | |||
| cd2b99a44c | |||
| 0413f8c0aa | |||
| ca5c451d36 | |||
| c19b87275d | |||
| 7a374a509a | |||
| 01aa2cf0a4 | |||
| 5c9eb5dc5a | |||
| b05a53ffe3 | |||
| 0387551c43 | |||
| 773f808a35 | |||
| 07477eb5f8 | |||
| 5cb129311a | |||
| 6215f489f2 | |||
| 0140fc7622 | |||
| fbaf6086e3 | |||
| 5a1b649011 | |||
| 6da42686a9 | |||
| 82b98eca95 | |||
| a54275d307 | |||
| e1e61e8570 | |||
| ebc7e7256a | |||
| 93887ec2bb | |||
| 8dce0f2d6a | |||
| 620ccf317f | |||
| 7983f17e7f | |||
| b60c0b29f8 | |||
| 5400a1424c | |||
| 3296d21f6a | |||
| 2e1a9362fc | |||
| 53aa4ff390 | |||
| 3c100b2543 | |||
| b37cde5a8c | |||
| e13efdc11f | |||
| 303f599f7d | |||
| a68c45a923 | |||
| 96b7c69283 | |||
| 801c0f8572 | |||
| 90e862fbb1 | |||
| 1eb997d2c0 | |||
| 1d314b1b09 | |||
| a077cccc2e | |||
| 6fb7e63e88 | |||
| 3621245212 | |||
| dfa573b49b | |||
| a69db9d299 | |||
| 481618037d | |||
| 57ead2937b | |||
| e6d8cbcdd6 | |||
| a7554d884f | |||
| 468a4b1bac | |||
| c1e4cd4bf1 | |||
| 4413f2c411 | |||
| b1349f57cd | |||
| 4a6f7eb43c | |||
| 8f0e6ba95a | |||
| e9c175d2af | |||
| 5dc0e925c1 | |||
| 787eaad352 | |||
| 4bab8db7c0 | |||
| b6e96586a5 | |||
| 7127c5539a | |||
| fe7736a7f6 | |||
| 29dcaaae47 | |||
| 9090c5c223 | |||
| 4336d1ed1a | |||
| 8edc3640f5 | |||
| 39e81af782 | |||
| cc16f57a44 | |||
| e7e7caae3b | |||
| e09c62cf8d | |||
| 8d5fe8f0f1 | |||
| f9d8293fd2 | |||
| 4111eb0838 | |||
| cd37e2ab1d | |||
| 2c730b3e25 | |||
| ee47ee91c3 | |||
| 5a47c74f83 | |||
| 0c4b8f006a | |||
| 0023454d9a | |||
| 45e6c56934 | |||
| 51eb8fa377 | |||
| f905dcb3fd | |||
| 583676314a | |||
| 8616183660 | |||
| ce4bca8272 | |||
| 8c71f03f6f | |||
| c5c4ddecbe | |||
| ceecd9d524 | |||
| a5a526e554 | |||
| 88864fd4f0 | |||
| 10f2b39203 | |||
| 6e1899c6ad | |||
| b94dc8eb83 | |||
| 70eea8ff33 | |||
| 881a9c122a | |||
| 9e9cac0c2e | |||
| 83b2a3645d | |||
| ecfca1c742 | |||
| 6222b6edae | |||
| baa6850fcb | |||
| cab13cee3c | |||
| b4bff49104 | |||
| cde26b76b1 | |||
| a20ef34280 | |||
| 5606d40451 | |||
| b3a666f876 | |||
| 626d3895aa | |||
| e338c7190d | |||
| adfd12ef52 | |||
| 817291c9a7 | |||
| c4d20c539f | |||
| d089eee133 | |||
| 387df07659 | |||
| 5767acb29b | |||
| 607c1ddc48 | |||
| a6d45f5009 | |||
| 6e9f427182 | |||
| 62ad1749e8 | |||
| 9c695ee46d | |||
| 1d2002e92f | |||
| c7b809415c | |||
| 55fa9ca686 | |||
| 09a6ea46b2 | |||
| 56ba4d88f4 | |||
| fbc8c5e8c7 | |||
| 8a65282820 | |||
| 0d8c5f3dbe | |||
| dbbbe6805d | |||
| 4656002106 | |||
| d3758ec02f | |||
| 81812bb31d | |||
| ab6f9839ef | |||
| d036f66d4c | |||
| dc24186ccb | |||
| f6fbcd8608 | |||
| 381d063295 | |||
| 65a0e48988 | |||
| b1a928b990 | |||
| b7973772b3 | |||
| 20b65fd885 | |||
| 850fbffdde | |||
| 0af5b194fc | |||
| c421038808 | |||
| 68c078038a | |||
| be5b1a52ea | |||
| 86c5b882c2 | |||
| b7d2653fb5 | |||
| 34a752b8d8 | |||
| de217a1bbf | |||
| 3087296263 | |||
| 78ba43480b | |||
| e7e2cc8081 | |||
| 0f6b61ce6c | |||
| 9182e8f2ef | |||
| c7870a79e5 | |||
| 312172fcd8 | |||
| 4a83ba84d3 | |||
| 76a9a20d89 | |||
| 567902a407 | |||
| fca8211c4f | |||
| d37b023e11 | |||
| f175644843 | |||
| 13c4ad10c5 | |||
| 550cd848b0 | |||
| 25e0c43c0b | |||
| fd9d23995d | |||
| 3a467d758e | |||
| 60b6adfa1e | |||
| a44ac333ab | |||
| deea0deb3f | |||
| 97178227ef | |||
| fd1242490f | |||
| 1ac633bdd7 | |||
| 44d4349f12 | |||
| df31be61e8 | |||
| 68b08c1e8a | |||
| f97cca6dcc | |||
| 2c9bb12da9 | |||
| b059055a93 | |||
| 1092cc2fdc | |||
| d5e75b2a37 | |||
| 0a5f8862ad | |||
| 433a225b9d | |||
| 6dfff84ab7 | |||
| 91b6047f2e | |||
| 94ef7f450b | |||
| e5cf92f84e | |||
| 399b5c03a2 | |||
| f9b7be2f74 | |||
| 66f7cc6f88 | |||
| af03597e86 | |||
| ab4fc8faf5 | |||
| fc2c5a8e6c | |||
| 1d23558dff | |||
| ce65d0257a | |||
| 78c5b1704a | |||
| 00f8b6d950 | |||
| e829bdccb5 | |||
| 6a7627e8c6 |
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -25,14 +25,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
with:
|
|
||||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Set up the environment
|
- name: Set up the environment
|
||||||
|
id: setup
|
||||||
run: |
|
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
|
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
|
||||||
cat >> docker/custom-backend-pre-setup.sh <<EOF
|
cat >> docker/custom-backend-pre-setup.sh <<EOF
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
@@ -54,6 +55,10 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
RELEASE_BUILD=1
|
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 }}
|
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -25,14 +25,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
with:
|
|
||||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Set up the environment
|
- name: Set up the environment
|
||||||
|
id: setup
|
||||||
run: |
|
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
|
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
|
||||||
cat >> docker/custom-backend-pre-setup.sh <<EOF
|
cat >> docker/custom-backend-pre-setup.sh <<EOF
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
@@ -53,6 +54,10 @@ jobs:
|
|||||||
platforms: ${{ vars.BUILD_SNAPSHOT_PLATFORMS }}
|
platforms: ${{ vars.BUILD_SNAPSHOT_PLATFORMS }}
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
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 }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.7"
|
||||||
|
mingw-version:
|
||||||
|
required: false
|
||||||
|
default: "15.2.0"
|
||||||
|
mingw-revison:
|
||||||
|
required: false
|
||||||
|
default: "v13-rev1"
|
||||||
|
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 ' *)
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -1,55 +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
|
|
||||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
@@ -1,53 +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: |
|
|
||||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
@@ -1,30 +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: |
|
|
||||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
|
||||||
+17
@@ -144,3 +144,20 @@ dist/
|
|||||||
# Visual Studio Code
|
# Visual Studio Code
|
||||||
.vscode/
|
.vscode/
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
|
|
||||||
|
# Roo Code
|
||||||
|
.roo/
|
||||||
|
|
||||||
|
# Binary and build files
|
||||||
|
ezbookkeeping
|
||||||
|
!**/ezbookkeeping/
|
||||||
|
package/
|
||||||
|
|
||||||
|
# Environment variable files
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
|
||||||
|
# Other directories
|
||||||
|
data/
|
||||||
|
storage/
|
||||||
|
log/
|
||||||
|
|||||||
+17
-3
@@ -1,8 +1,16 @@
|
|||||||
# Build backend binary file
|
# Build backend binary file
|
||||||
FROM golang:1.24.0-alpine3.21 AS be-builder
|
FROM golang:1.25.7-alpine3.23 AS be-builder
|
||||||
ARG RELEASE_BUILD
|
ARG RELEASE_BUILD
|
||||||
|
ARG BUILD_PIPELINE
|
||||||
|
ARG BUILD_UNIXTIME
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG CHECK_3RD_API
|
||||||
ARG SKIP_TESTS
|
ARG SKIP_TESTS
|
||||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
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
|
ENV SKIP_TESTS=$SKIP_TESTS
|
||||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -11,9 +19,15 @@ RUN apk add git gcc g++ libc-dev
|
|||||||
RUN ./build.sh backend
|
RUN ./build.sh backend
|
||||||
|
|
||||||
# Build frontend files
|
# Build frontend files
|
||||||
FROM --platform=$BUILDPLATFORM node:22.14.0-alpine3.21 AS fe-builder
|
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine3.23 AS fe-builder
|
||||||
ARG RELEASE_BUILD
|
ARG RELEASE_BUILD
|
||||||
|
ARG BUILD_PIPELINE
|
||||||
|
ARG BUILD_UNIXTIME
|
||||||
|
ARG BUILD_DATE
|
||||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
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
|
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN docker/frontend-build-pre-setup.sh
|
RUN docker/frontend-build-pre-setup.sh
|
||||||
@@ -21,7 +35,7 @@ RUN apk add git
|
|||||||
RUN ./build.sh frontend
|
RUN ./build.sh frontend
|
||||||
|
|
||||||
# Package docker image
|
# Package docker image
|
||||||
FROM alpine:3.21.3
|
FROM alpine:3.23.3
|
||||||
LABEL maintainer="MaysWind <i@mayswind.net>"
|
LABEL maintainer="MaysWind <i@mayswind.net>"
|
||||||
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
||||||
RUN apk --no-cache add tzdata
|
RUN apk --no-cache add tzdata
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,37 +1,60 @@
|
|||||||
# ezBookkeeping
|
# ezBookkeeping
|
||||||
[](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
[](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||||
[](https://github.com/mayswind/ezbookkeeping/actions)
|
|
||||||
[](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
|
[](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
|
||||||
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
|
||||||
[](https://github.com/mayswind/ezbookkeeping/releases)
|
[](https://github.com/mayswind/ezbookkeeping/releases)
|
||||||
|
[](https://github.com/mayswind/ezbookkeeping/actions)
|
||||||
|
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||||
|
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||||
|
[](https://deepwiki.com/mayswind/ezbookkeeping)
|
||||||
|
|
||||||
|
[](https://hellogithub.com/en/repository/mayswind/ezbookkeeping)
|
||||||
|
[](https://trendshift.io/repositories/12917)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including SQLite, MySQL and PostgreSQL. With docker, you can just deploy it via one command without complicated configuration.
|
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It helps you record daily transactions, import data from various sources, and quickly search and filter your bills. You can analyze historical data using built-in charts or perform custom queries with your own chart dimensions to better understand spending patterns and financial trends. ezBookkeeping is easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient, it runs smoothly on devices such as Raspberry Pi, NAS, and MicroServers.
|
||||||
|
|
||||||
Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
|
ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
|
||||||
|
|
||||||
|
Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
1. Open source & Self-hosted
|
- **Open Source & Self-Hosted**
|
||||||
2. Lightweight & Fast
|
- Built for privacy and control
|
||||||
3. Easy to install
|
- **Lightweight & Fast**
|
||||||
* Docker support
|
- Minimal resource usage, runs smoothly even on low-resource devices
|
||||||
* Multiple database support (SQLite, MySQL, PostgreSQL, etc.)
|
- **Easy Installation**
|
||||||
* Multiple operation system & hardware support (Windows, macOS, Linux & x86, amd64, ARM)
|
- Docker support
|
||||||
4. User-friendly interface
|
- Supports SQLite, MySQL, PostgreSQL
|
||||||
* Both desktop and mobile UI
|
- Cross-platform (Windows, macOS, Linux)
|
||||||
* Close to native app experience (for mobile device)
|
- Works on x86, amd64, ARM architectures
|
||||||
* Two-level account & two-level category support
|
- **User-Friendly Interface**
|
||||||
* Plentiful preset categories
|
- UI optimized for both mobile and desktop
|
||||||
* Geographic location and map support
|
- PWA support for native-like mobile experience
|
||||||
* Searching & filtering history records
|
- Dark mode
|
||||||
* Data statistics
|
- **AI-Powered Features**
|
||||||
* Dark theme
|
- Receipt image recognition
|
||||||
5. Multiple currency support & automatically updating exchange rates
|
- MCP (Model Context Protocol) support for AI integration
|
||||||
6. Multiple timezone support
|
- API command-line script tools for AI integration
|
||||||
7. Multi-language support
|
- **Powerful Bookkeeping**
|
||||||
8. Two-factor authentication
|
- Two-level accounts and categories
|
||||||
9. Application lock (PIN code / WebAuthn)
|
- Image attachments for transactions
|
||||||
10. Data export & import (OFX, QFX, QIF, IIF, CSV, GnuCash, FireFly III, etc.)
|
- Location tracking with maps
|
||||||
|
- Scheduled transactions
|
||||||
|
- Advanced filtering, search, visualization and analysis
|
||||||
|
- **Localization & Internationalization**
|
||||||
|
- Multi-language and multi-currency support
|
||||||
|
- Multiple exchange rate sources with automatic updates
|
||||||
|
- Multi-timezone support
|
||||||
|
- Custom formats for dates, numbers and currencies
|
||||||
|
- **Security**
|
||||||
|
- Two-factor authentication (2FA)
|
||||||
|
- OIDC external authentication
|
||||||
|
- Login rate limiting
|
||||||
|
- Application lock (PIN code / WebAuthn)
|
||||||
|
- **Data Import & Export**
|
||||||
|
- Supports CSV, OFX, QFX, QIF, IIF, Camt.052, Camt.053, MT940, GnuCash, Firefly III, Beancount and more
|
||||||
|
|
||||||
|
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/comparison/).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
### Desktop Version
|
### Desktop Version
|
||||||
@@ -41,19 +64,19 @@ Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-dem
|
|||||||
[](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)
|
[](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
### Ship with docker
|
### Run with Docker
|
||||||
Visit [Docker Hub](https://hub.docker.com/r/mayswind/ezbookkeeping) to see all images and tags.
|
Visit [Docker Hub](https://hub.docker.com/r/mayswind/ezbookkeeping) to see all images and tags.
|
||||||
|
|
||||||
Latest Release:
|
**Latest Release:**
|
||||||
|
|
||||||
$ docker run -p8080:8080 mayswind/ezbookkeeping
|
$ docker run -p8080:8080 mayswind/ezbookkeeping
|
||||||
|
|
||||||
Latest Daily Build:
|
**Latest Daily Build:**
|
||||||
|
|
||||||
$ docker run -p8080:8080 mayswind/ezbookkeeping:latest-snapshot
|
$ docker run -p8080:8080 mayswind/ezbookkeeping:latest-snapshot
|
||||||
|
|
||||||
### Install from binary
|
### Install from Binary
|
||||||
Latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://github.com/mayswind/ezbookkeeping/releases)
|
Download the latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://github.com/mayswind/ezbookkeeping/releases)
|
||||||
|
|
||||||
**Linux / macOS**
|
**Linux / macOS**
|
||||||
|
|
||||||
@@ -63,10 +86,10 @@ Latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://git
|
|||||||
|
|
||||||
> .\ezbookkeeping.exe server run
|
> .\ezbookkeeping.exe server run
|
||||||
|
|
||||||
ezBookkeeping will listen at port 8080 as default. Then you can visit `http://{YOUR_HOST_ADDRESS}:8080/` .
|
By default, ezBookkeeping listens on port 8080. You can then visit `http://{YOUR_HOST_ADDRESS}:8080/` .
|
||||||
|
|
||||||
### Build from source
|
### Build from Source
|
||||||
Make sure you have [Golang](https://golang.org/), [GCC](http://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
|
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**
|
**Linux / macOS**
|
||||||
|
|
||||||
@@ -78,17 +101,59 @@ All the files will be packaged in `ezbookkeeping.tar.gz`.
|
|||||||
|
|
||||||
> .\build.bat package -o ezbookkeeping.zip
|
> .\build.bat package -o ezbookkeeping.zip
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
PS > .\build.ps1 package -Output ezbookkeeping.zip
|
||||||
|
|
||||||
All the files will be packaged in `ezbookkeeping.zip`.
|
All the files will be packaged in `ezbookkeeping.zip`.
|
||||||
|
|
||||||
You can also build docker image, make sure you have [docker](https://www.docker.com/) installed, then follow these steps:
|
You can also build a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps:
|
||||||
|
|
||||||
**Linux**
|
**Linux**
|
||||||
|
|
||||||
$ ./build.sh docker
|
$ ./build.sh docker
|
||||||
|
|
||||||
## Documents
|
## Contributing
|
||||||
1. [English](http://ezbookkeeping.mayswind.net)
|
We welcome contributions of all kinds.
|
||||||
1. [中文 (简体)](http://ezbookkeeping.mayswind.net/zh_Hans)
|
|
||||||
|
If you find a bug, please [submit an issue](https://github.com/mayswind/ezbookkeeping/issues) on GitHub.
|
||||||
|
|
||||||
|
If you would like to contribute code, you can fork the repository and open a pull request.
|
||||||
|
|
||||||
|
Improvements to documentation, feature suggestions, and other forms of feedback are also appreciated.
|
||||||
|
|
||||||
|
You can view existing contributors on the [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors).
|
||||||
|
|
||||||
|
## Translating
|
||||||
|
Help make ezBookkeeping accessible to users around the world. We welcome help to improve existing translations or add new ones. If you would like to contribute a translation, please refer to the [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||||
|
|
||||||
|
Currently available translations:
|
||||||
|
|
||||||
|
| Tag | Language | Contributors |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
|
||||||
|
| en | English | / |
|
||||||
|
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@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), [@balaios](https://github.com/balaios) |
|
||||||
|
| ru | Русский | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
||||||
|
| 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 | 中文 (繁體) | / |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
1. [English](https://ezbookkeeping.mayswind.net)
|
||||||
|
1. [中文 (简体)](https://ezbookkeeping.mayswind.net/zh_Hans)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ set "RELEASE=%RELEASE_BUILD%"
|
|||||||
set "RELEASE_TYPE=unknown"
|
set "RELEASE_TYPE=unknown"
|
||||||
set "VERSION="
|
set "VERSION="
|
||||||
set "COMMIT_HASH="
|
set "COMMIT_HASH="
|
||||||
set "BUILD_UNIXTIME="
|
set "BUILD_UNIXTIME=%BUILD_UNIXTIME%"
|
||||||
set "BUILD_DATE="
|
set "BUILD_DATE=%BUILD_DATE%"
|
||||||
set "PACKAGE_FILENAME="
|
set "PACKAGE_FILENAME="
|
||||||
for /f %%a in ('"prompt $E$S & echo on & for %%b in (1) do rem"') do set "ESC=%%a"
|
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:,=%
|
set VERSION=%VERSION:,=%
|
||||||
set VERSION=%VERSION:"=%
|
set VERSION=%VERSION:"=%
|
||||||
for /f %%x in ('git rev-parse --short HEAD') do set "COMMIT_HASH=%%x"
|
for /f %%x in ('git rev-parse --short^=7 HEAD') do set "COMMIT_HASH=%%x"
|
||||||
call :set_unixtime BUILD_UNIXTIME
|
|
||||||
call :set_date BUILD_DATE
|
if "%BUILD_UNIXTIME%"=="" (
|
||||||
|
call :set_unixtime BUILD_UNIXTIME
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%BUILD_DATE%"=="" (
|
||||||
|
call :set_date BUILD_DATE
|
||||||
|
)
|
||||||
|
|
||||||
:main
|
:main
|
||||||
if "%TYPE%"=="backend" call :build_backend & goto :end
|
if "%TYPE%"=="backend" call :build_backend & goto :end
|
||||||
@@ -191,6 +197,17 @@ goto :pre_parse_args
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "%NO_TEST%"=="0" (
|
||||||
|
echo Executing frontend unit testing...
|
||||||
|
|
||||||
|
call npm run test
|
||||||
|
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
call :echo_red "Error: Failed to pass unit testing"
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
endlocal
|
endlocal
|
||||||
|
|
||||||
echo Building frontend files(%RELEASE_TYPE%)...
|
echo Building frontend files(%RELEASE_TYPE%)...
|
||||||
@@ -250,7 +267,7 @@ goto :pre_parse_args
|
|||||||
goto :end
|
goto :end
|
||||||
)
|
)
|
||||||
|
|
||||||
call 7z a -r -tzip -mx9 ..\%package_file_name% package *
|
call 7z a -r -tzip -mx9 ..\%package_file_name% *
|
||||||
|
|
||||||
cd ..
|
cd ..
|
||||||
endlocal
|
endlocal
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,7 +8,8 @@ RELEASE=${RELEASE_BUILD:-"0"}
|
|||||||
RELEASE_TYPE="unknown"
|
RELEASE_TYPE="unknown"
|
||||||
VERSION=""
|
VERSION=""
|
||||||
COMMIT_HASH=""
|
COMMIT_HASH=""
|
||||||
BUILD_UNIXTIME=""
|
BUILD_UNIXTIME="${BUILD_UNIXTIME}"
|
||||||
|
BUILD_DATE="${BUILD_DATE}"
|
||||||
PACKAGE_FILENAME=""
|
PACKAGE_FILENAME=""
|
||||||
DOCKER_TAG=""
|
DOCKER_TAG=""
|
||||||
|
|
||||||
@@ -117,8 +118,15 @@ check_type_dependencies() {
|
|||||||
|
|
||||||
set_build_parameters() {
|
set_build_parameters() {
|
||||||
VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')"
|
VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')"
|
||||||
COMMIT_HASH="$(git rev-parse --short HEAD)"
|
COMMIT_HASH="$(git rev-parse --short=7 HEAD)"
|
||||||
BUILD_UNIXTIME="$(date '+%s')"
|
|
||||||
|
if [ -z "$BUILD_UNIXTIME" ]; then
|
||||||
|
BUILD_UNIXTIME="$(date '+%s')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$BUILD_DATE" ]; then
|
||||||
|
BUILD_DATE="$(date '+%Y%m%d')"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
build_backend() {
|
build_backend() {
|
||||||
@@ -179,6 +187,17 @@ build_frontend() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$NO_TEST" = "0" ]; then
|
||||||
|
echo "Executing frontend unit testing..."
|
||||||
|
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
if [ "$?" != "0" ]; then
|
||||||
|
echo_red "Error: Failed to pass unit testing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Building frontend files ($RELEASE_TYPE)..."
|
echo "Building frontend files ($RELEASE_TYPE)..."
|
||||||
|
|
||||||
if [ "$RELEASE" = "0" ]; then
|
if [ "$RELEASE" = "0" ]; then
|
||||||
@@ -192,7 +211,7 @@ build_package() {
|
|||||||
package_file_name="$VERSION";
|
package_file_name="$VERSION";
|
||||||
|
|
||||||
if [ "$RELEASE" = "0" ]; then
|
if [ "$RELEASE" = "0" ]; then
|
||||||
package_file_name="$package_file_name-$(date '+%Y%m%d')"
|
package_file_name="$package_file_name-$BUILD_DATE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
package_file_name="ezbookkeeping-$package_file_name-$(arch).tar.gz"
|
package_file_name="ezbookkeeping-$package_file_name-$(arch).tar.gz"
|
||||||
@@ -226,7 +245,7 @@ build_docker() {
|
|||||||
docker_tag="$VERSION"
|
docker_tag="$VERSION"
|
||||||
|
|
||||||
if [ "$RELEASE" = "0" ]; then
|
if [ "$RELEASE" = "0" ]; then
|
||||||
docker_tag="SNAPSHOT-$(date '+%Y%m%d')";
|
docker_tag="SNAPSHOT-$BUILD_DATE";
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker_tag="ezbookkeeping:$docker_tag"
|
docker_tag="ezbookkeeping:$docker_tag"
|
||||||
|
|||||||
+5
-3
@@ -1,14 +1,16 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/urfave/cli/v2"
|
"context"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func bindAction(fn core.CliHandlerFunc) cli.ActionFunc {
|
func bindAction(fn core.CliHandlerFunc) cli.ActionFunc {
|
||||||
return func(cliCtx *cli.Context) error {
|
return func(ctx context.Context, cmd *cli.Command) error {
|
||||||
c := core.WrapCilContext(cliCtx)
|
c := core.WrapCilContext(ctx, cmd)
|
||||||
return fn(c)
|
return fn(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/cron"
|
"github.com/mayswind/ezbookkeeping/pkg/cron"
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
var CronJobs = &cli.Command{
|
var CronJobs = &cli.Command{
|
||||||
Name: "cron",
|
Name: "cron",
|
||||||
Usage: "ezBookkeeping cron job utilities",
|
Usage: "ezBookkeeping cron job utilities",
|
||||||
Subcommands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Usage: "List all enabled cron jobs",
|
Usage: "List all enabled cron jobs",
|
||||||
|
|||||||
+42
-2
@@ -1,7 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
var Database = &cli.Command{
|
var Database = &cli.Command{
|
||||||
Name: "database",
|
Name: "database",
|
||||||
Usage: "ezBookkeeping database maintenance",
|
Usage: "ezBookkeeping database maintenance",
|
||||||
Subcommands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "update",
|
Name: "update",
|
||||||
Usage: "Update database structure",
|
Usage: "Update database structure",
|
||||||
@@ -101,6 +101,14 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error {
|
|||||||
|
|
||||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
|
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))
|
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTag))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,5 +141,37 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error {
|
|||||||
|
|
||||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction picture table maintained successfully")
|
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction picture table maintained successfully")
|
||||||
|
|
||||||
|
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserCustomExchangeRate))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user custom exchange rate table maintained successfully")
|
||||||
|
|
||||||
|
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserApplicationCloudSetting))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user application cloud settings table maintained successfully")
|
||||||
|
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-5
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/mail"
|
"github.com/mayswind/ezbookkeeping/pkg/mail"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
@@ -90,6 +91,15 @@ func initializeSystem(c *core.CliContext) (*settings.Config, error) {
|
|||||||
return nil, err
|
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)
|
err = uuid.InitializeUuidGenerator(config)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,11 +162,63 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
clonedConfig.DatabaseConfig.DatabasePassword = "****"
|
if clonedConfig.DatabaseConfig.DatabasePassword != "" {
|
||||||
clonedConfig.SMTPConfig.SMTPPasswd = "****"
|
clonedConfig.DatabaseConfig.DatabasePassword = "****"
|
||||||
clonedConfig.MinIOConfig.SecretAccessKey = "****"
|
}
|
||||||
clonedConfig.SecretKey = "****"
|
|
||||||
clonedConfig.AmapApplicationSecret = "****"
|
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.AnthropicCompatibleAPIKey != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey = "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey = "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken = "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey = "****"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clonedConfig.OAuth2ClientSecret != "" {
|
||||||
|
clonedConfig.OAuth2ClientSecret = "****"
|
||||||
|
}
|
||||||
|
|
||||||
return clonedConfig
|
return clonedConfig
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
var SecurityUtils = &cli.Command{
|
var SecurityUtils = &cli.Command{
|
||||||
Name: "security",
|
Name: "security",
|
||||||
Usage: "ezBookkeeping security utilities",
|
Usage: "ezBookkeeping security utilities",
|
||||||
Subcommands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "gen-secret-key",
|
Name: "gen-secret-key",
|
||||||
Usage: "Generate a random secret key",
|
Usage: "Generate a random secret key",
|
||||||
|
|||||||
+72
-4
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
clis "github.com/mayswind/ezbookkeeping/pkg/cli"
|
clis "github.com/mayswind/ezbookkeeping/pkg/cli"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
var UserData = &cli.Command{
|
var UserData = &cli.Command{
|
||||||
Name: "userdata",
|
Name: "userdata",
|
||||||
Usage: "ezBookkeeping user data maintenance",
|
Usage: "ezBookkeeping user data maintenance",
|
||||||
Subcommands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "user-add",
|
Name: "user-add",
|
||||||
Usage: "Add new user",
|
Usage: "Add new user",
|
||||||
@@ -260,6 +260,31 @@ var UserData = &cli.Command{
|
|||||||
Required: true,
|
Required: true,
|
||||||
Usage: "Specific user name",
|
Usage: "Specific user name",
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "type",
|
||||||
|
Aliases: []string{"t"},
|
||||||
|
Required: false,
|
||||||
|
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",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -702,7 +727,24 @@ func createNewUserToken(c *core.CliContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
username := c.String("username")
|
username := c.String("username")
|
||||||
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username)
|
tokenType := c.String("type")
|
||||||
|
expiresInSeconds := c.Int64("expiresInSeconds")
|
||||||
|
|
||||||
|
if tokenType == "" {
|
||||||
|
tokenType = "api"
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenType != "api" && tokenType != "mcp" {
|
||||||
|
log.CliErrorf(c, "[user_data.createNewUserToken] token type is invalid")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
|
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
|
||||||
@@ -715,6 +757,26 @@ func createNewUserToken(c *core.CliContext) error {
|
|||||||
return nil
|
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 {
|
func clearUserTokens(c *core.CliContext) error {
|
||||||
_, err := initializeSystem(c)
|
_, err := initializeSystem(c)
|
||||||
|
|
||||||
@@ -895,14 +957,20 @@ func printUserInfo(user *models.User) {
|
|||||||
fmt.Printf("[Language] %s\n", user.Language)
|
fmt.Printf("[Language] %s\n", user.Language)
|
||||||
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
|
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
|
||||||
fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek)
|
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("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat)
|
||||||
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
|
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
|
||||||
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
|
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
|
||||||
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
|
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("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
|
||||||
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
|
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
|
||||||
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping)
|
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("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
|
||||||
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
|
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
|
||||||
fmt.Printf("[FeatureRestriction] %s (%d)\n", user.FeatureRestriction, user.FeatureRestriction)
|
fmt.Printf("[FeatureRestriction] %s (%d)\n", user.FeatureRestriction, user.FeatureRestriction)
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
var Utilities = &cli.Command{
|
var Utilities = &cli.Command{
|
||||||
Name: "utility",
|
Name: "utility",
|
||||||
Usage: "ezBookkeeping utilities",
|
Usage: "ezBookkeeping utilities",
|
||||||
Subcommands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "parse-default-request-id",
|
Name: "parse-default-request-id",
|
||||||
Usage: "Parse a request id which is generated by default request generator and show the details",
|
Usage: "Parse a request id which is generated by default request generator and show the details",
|
||||||
@@ -81,13 +81,13 @@ func sendTestMail(c *core.CliContext) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !config.EnableSMTP || mail.Container.Current == nil {
|
if !config.EnableSMTP {
|
||||||
return errs.ErrSMTPServerNotEnabled
|
return errs.ErrSMTPServerNotEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
toAddress := c.String("to")
|
toAddress := c.String("to")
|
||||||
|
|
||||||
err = mail.Container.Current.SendMail(&mail.MailMessage{
|
err = mail.Container.SendMail(&mail.MailMessage{
|
||||||
To: toAddress,
|
To: toAddress,
|
||||||
Subject: "ezBookkeeping test e-mail",
|
Subject: "ezBookkeeping test e-mail",
|
||||||
Body: "This is a test e-mail",
|
Body: "This is a test e-mail",
|
||||||
|
|||||||
+196
-20
@@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,13 +12,15 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gin-gonic/gin/binding"
|
"github.com/gin-gonic/gin/binding"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/api"
|
"github.com/mayswind/ezbookkeeping/pkg/api"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/cron"
|
"github.com/mayswind/ezbookkeeping/pkg/cron"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/mcp"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/middlewares"
|
"github.com/mayswind/ezbookkeeping/pkg/middlewares"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/requestid"
|
"github.com/mayswind/ezbookkeeping/pkg/requestid"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
@@ -29,7 +32,7 @@ import (
|
|||||||
var WebServer = &cli.Command{
|
var WebServer = &cli.Command{
|
||||||
Name: "server",
|
Name: "server",
|
||||||
Usage: "ezBookkeeping web server operation",
|
Usage: "ezBookkeeping web server operation",
|
||||||
Subcommands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "run",
|
Name: "run",
|
||||||
Usage: "Run ezBookkeeping web server",
|
Usage: "Run ezBookkeeping web server",
|
||||||
@@ -63,6 +66,20 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = mcp.InitializeMCPHandlers(config)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.BootErrorf(c, "[webserver.startWebServer] initializes mcp handlers failed, because %s", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = 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)
|
err = cron.InitializeCronJobSchedulerContainer(c, config, true)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,7 +87,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
return err
|
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 := ""
|
uuidServerInfo := ""
|
||||||
if config.UuidGeneratorType == settings.InternalUuidGeneratorType {
|
if config.UuidGeneratorType == settings.InternalUuidGeneratorType {
|
||||||
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
|
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
|
||||||
@@ -95,9 +112,12 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
_ = v.RegisterValidation("notBlank", validators.NotBlank)
|
_ = v.RegisterValidation("notBlank", validators.NotBlank)
|
||||||
_ = v.RegisterValidation("validUsername", validators.ValidUsername)
|
_ = v.RegisterValidation("validUsername", validators.ValidUsername)
|
||||||
_ = v.RegisterValidation("validEmail", validators.ValidEmail)
|
_ = v.RegisterValidation("validEmail", validators.ValidEmail)
|
||||||
|
_ = v.RegisterValidation("validNickname", validators.ValidNickname)
|
||||||
_ = v.RegisterValidation("validCurrency", validators.ValidCurrency)
|
_ = v.RegisterValidation("validCurrency", validators.ValidCurrency)
|
||||||
_ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor)
|
_ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor)
|
||||||
_ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter)
|
_ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter)
|
||||||
|
_ = v.RegisterValidation("validTagFilter", validators.ValidTagFilter)
|
||||||
|
_ = v.RegisterValidation("validFiscalYearStart", validators.ValidateFiscalYearStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
router.NoRoute(bindApi(api.Default.ApiNotFound))
|
router.NoRoute(bindApi(api.Default.ApiNotFound))
|
||||||
@@ -157,7 +177,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
|
|
||||||
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
||||||
avatarRoute := router.Group("/avatar")
|
avatarRoute := router.Group("/avatar")
|
||||||
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||||
{
|
{
|
||||||
avatarRoute.GET("/:fileName", bindImage(api.Users.UserGetAvatarHandler))
|
avatarRoute.GET("/:fileName", bindImage(api.Users.UserGetAvatarHandler))
|
||||||
}
|
}
|
||||||
@@ -165,7 +185,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
|
|
||||||
if config.EnableTransactionPictures {
|
if config.EnableTransactionPictures {
|
||||||
pictureRoute := router.Group("/pictures")
|
pictureRoute := router.Group("/pictures")
|
||||||
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||||
{
|
{
|
||||||
pictureRoute.GET("/:fileName", bindImage(api.TransactionPictures.TransactionPictureGetHandler))
|
pictureRoute.GET("/:fileName", bindImage(api.TransactionPictures.TransactionPictureGetHandler))
|
||||||
}
|
}
|
||||||
@@ -174,7 +194,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
|
router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
|
||||||
|
|
||||||
proxyRoute := router.Group("/proxy")
|
proxyRoute := router.Group("/proxy")
|
||||||
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||||
{
|
{
|
||||||
if config.EnableMapDataFetchProxy {
|
if config.EnableMapDataFetchProxy {
|
||||||
if config.MapProvider == settings.OpenStreetMapProvider ||
|
if config.MapProvider == settings.OpenStreetMapProvider ||
|
||||||
@@ -198,7 +218,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
|
|
||||||
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
|
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
|
||||||
amapApiProxyRoute := router.Group("/_AMapService")
|
amapApiProxyRoute := router.Group("/_AMapService")
|
||||||
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie))
|
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie(config)))
|
||||||
{
|
{
|
||||||
amapApiProxyRoute.GET("/*action", bindProxy(api.AmapApis.AmapApiProxyHandler))
|
amapApiProxyRoute.GET("/*action", bindProxy(api.AmapApis.AmapApiProxyHandler))
|
||||||
}
|
}
|
||||||
@@ -211,23 +231,64 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
qrCodeRoute.GET("/mobile_url.png", bindCachedImage(api.QrCodes.MobileUrlQrCodeHandler, qrCodeCacheStore))
|
qrCodeRoute.GET("/mobile_url.png", bindCachedImage(api.QrCodes.MobileUrlQrCodeHandler, qrCodeCacheStore))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.EnableMCPServer {
|
||||||
|
mcpRoute := router.Group("/mcp")
|
||||||
|
mcpRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||||
|
mcpRoute.Use(bindMiddleware(middlewares.RequestLog))
|
||||||
|
mcpRoute.Use(bindMiddleware(middlewares.MCPServerIpLimit(config)))
|
||||||
|
mcpRoute.Use(bindMiddleware(middlewares.JWTMCPAuthorization(config)))
|
||||||
|
{
|
||||||
|
mcpRoute.POST("", bindJSONRPCApi(map[string]core.JSONRPCApiHandlerFunc{
|
||||||
|
"initialize": api.ModelContextProtocols.InitializeHandler,
|
||||||
|
"resources/list": api.ModelContextProtocols.ListResourcesHandler,
|
||||||
|
"resources/read": api.ModelContextProtocols.ReadResourceHandler,
|
||||||
|
"tools/list": api.ModelContextProtocols.ListToolsHandler,
|
||||||
|
"tools/call": api.ModelContextProtocols.CallToolHandler,
|
||||||
|
"ping": api.ModelContextProtocols.PingHandler,
|
||||||
|
}, map[string]int{
|
||||||
|
"notifications/initialized": http.StatusAccepted,
|
||||||
|
}))
|
||||||
|
mcpRoute.GET("", bindApi(api.Default.MethodNotAllowed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := router.Group("/api")
|
||||||
|
|
||||||
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||||
apiRoute.Use(bindMiddleware(middlewares.RequestLog))
|
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 := 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("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeHandler, config))
|
||||||
twoFactorRoute.POST("/recovery.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler, 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))
|
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,17 +296,17 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler))
|
apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler))
|
||||||
|
|
||||||
emailVerifyRoute := apiRoute.Group("/verify_email")
|
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))
|
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))
|
apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler))
|
||||||
|
|
||||||
resetPasswordRoute := apiRoute.Group("/forget_password/reset")
|
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))
|
resetPasswordRoute.POST("/by_token.json", bindApi(api.ForgetPasswords.UserResetPasswordHandler))
|
||||||
}
|
}
|
||||||
@@ -254,10 +315,13 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config))
|
apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config))
|
||||||
|
|
||||||
apiV1Route := apiRoute.Group("/v1")
|
apiV1Route := apiRoute.Group("/v1")
|
||||||
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization))
|
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization(config)))
|
||||||
|
apiV1Route.Use(bindMiddleware(middlewares.APITokenIpLimit(config)))
|
||||||
{
|
{
|
||||||
// Tokens
|
// Tokens
|
||||||
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
|
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.json", bindApi(api.Tokens.TokenRevokeHandler))
|
||||||
apiV1Route.POST("/tokens/revoke_all.json", bindApi(api.Tokens.TokenRevokeAllHandler))
|
apiV1Route.POST("/tokens/revoke_all.json", bindApi(api.Tokens.TokenRevokeAllHandler))
|
||||||
apiV1Route.POST("/tokens/refresh.json", bindApiWithTokenUpdate(api.Tokens.TokenRefreshHandler, config))
|
apiV1Route.POST("/tokens/refresh.json", bindApiWithTokenUpdate(api.Tokens.TokenRefreshHandler, config))
|
||||||
@@ -275,6 +339,17 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
|
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))
|
||||||
|
apiV1Route.POST("/users/settings/cloud/disable.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsDisableHandler))
|
||||||
|
|
||||||
// Two-Factor Authorization
|
// Two-Factor Authorization
|
||||||
if config.EnableTwoFactor {
|
if config.EnableTwoFactor {
|
||||||
apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler))
|
apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler))
|
||||||
@@ -286,7 +361,9 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
apiV1Route.GET("/data/statistics.json", bindApi(api.DataManagements.DataStatisticsHandler))
|
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 {
|
if config.EnableDataExport {
|
||||||
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
|
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
|
||||||
@@ -301,23 +378,29 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiV1Route.POST("/accounts/hide.json", bindApi(api.Accounts.AccountHideHandler))
|
apiV1Route.POST("/accounts/hide.json", bindApi(api.Accounts.AccountHideHandler))
|
||||||
apiV1Route.POST("/accounts/move.json", bindApi(api.Accounts.AccountMoveHandler))
|
apiV1Route.POST("/accounts/move.json", bindApi(api.Accounts.AccountMoveHandler))
|
||||||
apiV1Route.POST("/accounts/delete.json", bindApi(api.Accounts.AccountDeleteHandler))
|
apiV1Route.POST("/accounts/delete.json", bindApi(api.Accounts.AccountDeleteHandler))
|
||||||
|
apiV1Route.POST("/accounts/sub_account/delete.json", bindApi(api.Accounts.SubAccountDeleteHandler))
|
||||||
|
|
||||||
// Transactions
|
// Transactions
|
||||||
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
|
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
|
||||||
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
|
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
|
||||||
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
|
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.json", bindApi(api.Transactions.TransactionStatisticsHandler))
|
||||||
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
|
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/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
|
||||||
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
|
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
|
||||||
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
|
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
|
||||||
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
|
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))
|
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
||||||
|
|
||||||
if config.EnableDataImport {
|
if config.EnableDataImport {
|
||||||
apiV1Route.POST("/transactions/parse_dsv_file.json", bindApi(api.Transactions.TransactionParseImportDsvFileDataHandler))
|
apiV1Route.POST("/transactions/parse_custom_file.json", bindApi(api.Transactions.TransactionParseImportCustomFileDataHandler))
|
||||||
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
||||||
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
||||||
|
apiV1Route.GET("/transactions/import/process.json", bindApi(api.Transactions.TransactionImportProcessHandler))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transaction Pictures
|
// Transaction Pictures
|
||||||
@@ -336,10 +419,19 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiV1Route.POST("/transaction/categories/move.json", bindApi(api.TransactionCategories.CategoryMoveHandler))
|
apiV1Route.POST("/transaction/categories/move.json", bindApi(api.TransactionCategories.CategoryMoveHandler))
|
||||||
apiV1Route.POST("/transaction/categories/delete.json", bindApi(api.TransactionCategories.CategoryDeleteHandler))
|
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
|
// Transaction Tags
|
||||||
apiV1Route.GET("/transaction/tags/list.json", bindApi(api.TransactionTags.TagListHandler))
|
apiV1Route.GET("/transaction/tags/list.json", bindApi(api.TransactionTags.TagListHandler))
|
||||||
apiV1Route.GET("/transaction/tags/get.json", bindApi(api.TransactionTags.TagGetHandler))
|
apiV1Route.GET("/transaction/tags/get.json", bindApi(api.TransactionTags.TagGetHandler))
|
||||||
apiV1Route.POST("/transaction/tags/add.json", bindApi(api.TransactionTags.TagCreateHandler))
|
apiV1Route.POST("/transaction/tags/add.json", bindApi(api.TransactionTags.TagCreateHandler))
|
||||||
|
apiV1Route.POST("/transaction/tags/add_batch.json", bindApi(api.TransactionTags.TagCreateBatchHandler))
|
||||||
apiV1Route.POST("/transaction/tags/modify.json", bindApi(api.TransactionTags.TagModifyHandler))
|
apiV1Route.POST("/transaction/tags/modify.json", bindApi(api.TransactionTags.TagModifyHandler))
|
||||||
apiV1Route.POST("/transaction/tags/hide.json", bindApi(api.TransactionTags.TagHideHandler))
|
apiV1Route.POST("/transaction/tags/hide.json", bindApi(api.TransactionTags.TagHideHandler))
|
||||||
apiV1Route.POST("/transaction/tags/move.json", bindApi(api.TransactionTags.TagMoveHandler))
|
apiV1Route.POST("/transaction/tags/move.json", bindApi(api.TransactionTags.TagMoveHandler))
|
||||||
@@ -354,8 +446,29 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
|
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
|
||||||
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
|
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
|
// Exchange Rates
|
||||||
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
|
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
|
||||||
|
apiV1Route.POST("/exchange_rates/user_custom/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler))
|
||||||
|
apiV1Route.POST("/exchange_rates/user_custom/delete.json", bindApi(api.ExchangeRates.UserCustomExchangeRateDeleteHandler))
|
||||||
|
|
||||||
|
// System
|
||||||
|
apiV1Route.GET("/systems/version.json", bindApi(api.Systems.VersionHandler))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +501,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 {
|
func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
|
||||||
return func(ginCtx *gin.Context) {
|
return func(ginCtx *gin.Context) {
|
||||||
c := core.WrapWebContext(ginCtx)
|
c := core.WrapWebContext(ginCtx)
|
||||||
@@ -418,6 +544,56 @@ func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bindJSONRPCApi(fns map[string]core.JSONRPCApiHandlerFunc, skipMethods map[string]int) gin.HandlerFunc {
|
||||||
|
return func(ginCtx *gin.Context) {
|
||||||
|
c := core.WrapWebContext(ginCtx)
|
||||||
|
|
||||||
|
var jsonRPCRequest core.JSONRPCRequest
|
||||||
|
reqErr := c.ShouldBindBodyWithJSON(&jsonRPCRequest)
|
||||||
|
|
||||||
|
if reqErr != nil {
|
||||||
|
utils.PrintJSONRPCErrorResult(c, nil, errs.NewIncompleteOrIncorrectSubmissionError(reqErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if skipMethods != nil {
|
||||||
|
httpStatusCode, exists := skipMethods[jsonRPCRequest.Method]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
c.AbortWithStatus(httpStatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn, exists := fns[jsonRPCRequest.Method]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
utils.PrintJSONRPCErrorResult(c, &jsonRPCRequest, errs.ErrApiNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := fn(c, &jsonRPCRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
utils.PrintJSONRPCErrorResult(c, &jsonRPCRequest, err)
|
||||||
|
} else {
|
||||||
|
utils.PrintJSONRPCSuccessResult(c, &jsonRPCRequest, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindEventStreamApi(fn core.EventStreamApiHandlerFunc) gin.HandlerFunc {
|
||||||
|
return func(ginCtx *gin.Context) {
|
||||||
|
c := core.WrapWebContext(ginCtx)
|
||||||
|
utils.SetEventStreamHeader(c)
|
||||||
|
err := fn(c)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteEventStreamJsonErrorResult(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
|
func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
|
||||||
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
|
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
|
||||||
c := core.WrapWebContext(ginCtx)
|
c := core.WrapWebContext(ginCtx)
|
||||||
@@ -426,7 +602,7 @@ func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.Han
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintDataErrorResult(c, "text/javascript", err)
|
utils.PrintDataErrorResult(c, "text/javascript", err)
|
||||||
} else {
|
} else {
|
||||||
utils.PrintDataSuccessResult(c, "text/javascript", "", result)
|
utils.PrintDataSuccessResult(c, "text/javascript; charset=utf-8", "", result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -439,7 +615,7 @@ func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintDataErrorResult(c, "text/text", err)
|
utils.PrintDataErrorResult(c, "text/text", err)
|
||||||
} else {
|
} else {
|
||||||
utils.PrintDataSuccessResult(c, "text/csv", fileName, result)
|
utils.PrintDataSuccessResult(c, "text/csv; charset=utf-8", fileName, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,7 +628,7 @@ func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintDataErrorResult(c, "text/text", err)
|
utils.PrintDataErrorResult(c, "text/text", err)
|
||||||
} else {
|
} else {
|
||||||
utils.PrintDataSuccessResult(c, "text/tab-separated-values", fileName, result)
|
utils.PrintDataSuccessResult(c, "text/tab-separated-values; charset=utf-8", fileName, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-18
@@ -1,7 +1,4 @@
|
|||||||
[global]
|
[global]
|
||||||
# Application instance name
|
|
||||||
app_name = ezBookkeeping
|
|
||||||
|
|
||||||
# Either "production", "development"
|
# Either "production", "development"
|
||||||
mode = production
|
mode = production
|
||||||
|
|
||||||
@@ -18,7 +15,7 @@ http_port = 8080
|
|||||||
# The domain name used to access ezBookkeeping
|
# The domain name used to access ezBookkeeping
|
||||||
domain = localhost
|
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/
|
root_url = %(protocol)s://%(domain)s:%(http_port)s/
|
||||||
|
|
||||||
# https certification and its key file
|
# https certification and its key file
|
||||||
@@ -37,6 +34,16 @@ enable_gzip = false
|
|||||||
# Set to true to log each request and execution time
|
# Set to true to log each request and execution time
|
||||||
log_request = true
|
log_request = true
|
||||||
|
|
||||||
|
# Add X-Request-Id header to response to track user request or error, default is true
|
||||||
|
request_id_header = true
|
||||||
|
|
||||||
|
[mcp]
|
||||||
|
# Set to true to enable MCP (Model Context Protocol) server (via http / https web server) for AI/LLM access
|
||||||
|
enable_mcp = false
|
||||||
|
|
||||||
|
# MCP server allowed remote IPs, a comma-separated list of allowed remote IPs (asterisk * for any addresses, e.g. 192.168.1.* means any IPs in the 192.168.1.x subnet), leave blank to allow all remote IPs
|
||||||
|
mcp_allowed_remote_ips =
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# Either "mysql", "postgres" or "sqlite3"
|
# Either "mysql", "postgres" or "sqlite3"
|
||||||
type = sqlite3
|
type = sqlite3
|
||||||
@@ -108,7 +115,7 @@ log_file_max_size = 104857600
|
|||||||
log_file_max_days = 7
|
log_file_max_days = 7
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
# Object storage type, supports "local_filesystem" and "minio" currently
|
# Object storage type, supports "local_filesystem", "minio" and "webdav" currently
|
||||||
type = local_filesystem
|
type = local_filesystem
|
||||||
|
|
||||||
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
|
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
|
||||||
@@ -132,6 +139,115 @@ minio_bucket = ezbookkeeping
|
|||||||
# For "minio" storage only, the root path to store files in minio
|
# For "minio" storage only, the root path to store files in minio
|
||||||
minio_root_path = /
|
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", "anthropic", "anthropic_compatible", "openrouter", "ollama", "lm_studio", "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 "anthropic" llm provider only, Anthropic API key, please visit https://platform.claude.com/settings/keys for more information
|
||||||
|
anthropic_api_key =
|
||||||
|
|
||||||
|
# For "anthropic" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
anthropic_model_id =
|
||||||
|
|
||||||
|
# For "anthropic" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||||
|
anthropic_max_tokens = 1024
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, Anthropic compatible API base url, e.g. "https://api.anthropic.com/v1/"
|
||||||
|
anthropic_compatible_base_url =
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, Anthropic compatible API version, e.g. "2023-06-01". If the LLM service does not require API versioning, leave it blank
|
||||||
|
anthropic_compatible_api_version =
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, Anthropic compatible API secret key
|
||||||
|
anthropic_compatible_api_key =
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
anthropic_compatible_model_id =
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||||
|
anthropic_compatible_max_tokens = 1024
|
||||||
|
|
||||||
|
# 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 "lm_studio" llm provider only, LM Studio server url, e.g. "http://127.0.0.1:1234/"
|
||||||
|
lm_studio_server_url =
|
||||||
|
|
||||||
|
# For "lm_studio" llm provider only, LM Studio API token, if "require authentication" is not enabled in LM Studio, leave it blank
|
||||||
|
lm_studio_token =
|
||||||
|
|
||||||
|
# For "lm_studio" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
lm_studio_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]
|
||||||
# Uuid generator type, supports "internal" currently
|
# Uuid generator type, supports "internal" currently
|
||||||
generator_type = internal
|
generator_type = internal
|
||||||
@@ -161,9 +277,6 @@ enable_create_scheduled_transaction = true
|
|||||||
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
|
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
|
||||||
secret_key =
|
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 seconds (60 - 4294967295), default is 2592000 (30 days)
|
||||||
token_expired_time = 2592000
|
token_expired_time = 2592000
|
||||||
|
|
||||||
@@ -180,14 +293,84 @@ email_verify_token_expired_time = 3600
|
|||||||
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
|
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
|
||||||
password_reset_token_expired_time = 3600
|
password_reset_token_expired_time = 3600
|
||||||
|
|
||||||
|
# Set to true to enable API token generation
|
||||||
|
enable_api_token = false
|
||||||
|
|
||||||
|
# Allowed remote IPs for using the API token, a comma-separated list of allowed remote IPs (asterisk * for any addresses, e.g. 192.168.1.* means any IPs in the 192.168.1.x subnet), leave blank to allow all remote IPs
|
||||||
|
api_token_allowed_remote_ips =
|
||||||
|
|
||||||
# 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
|
# 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
|
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
|
# 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
|
max_failures_per_user_per_minute = 5
|
||||||
|
|
||||||
# Add X-Request-Id header to response to track user request or error, default is true
|
[auth]
|
||||||
request_id_header = true
|
# 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]
|
[user]
|
||||||
# Set to true to allow users to register account by themselves
|
# Set to true to allow users to register account by themselves
|
||||||
@@ -199,12 +382,6 @@ enable_email_verify = false
|
|||||||
# Set to true to require email must be verified when login
|
# Set to true to require email must be verified when login
|
||||||
enable_force_email_verify = false
|
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
|
# Set to true to allow users to upload transaction pictures
|
||||||
enable_transaction_picture = true
|
enable_transaction_picture = true
|
||||||
|
|
||||||
@@ -236,6 +413,12 @@ max_user_avatar_size = 1048576
|
|||||||
# 9: Import Transactions
|
# 9: Import Transactions
|
||||||
# 10: Export Transactions
|
# 10: Export Transactions
|
||||||
# 11: Clear All Data
|
# 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 =
|
default_feature_restrictions =
|
||||||
|
|
||||||
[data]
|
[data]
|
||||||
@@ -346,7 +529,6 @@ custom_map_tile_server_default_zoom_level = 14
|
|||||||
|
|
||||||
[exchange_rates]
|
[exchange_rates]
|
||||||
# Exchange rates data source, supports the following types:
|
# Exchange rates data source, supports the following types:
|
||||||
# "reserve_bank_of_australia": https://www.rba.gov.au/statistics/frequency/exchange-rates.html
|
|
||||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||||
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
||||||
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
|
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
|
||||||
@@ -360,8 +542,9 @@ custom_map_tile_server_default_zoom_level = 14
|
|||||||
# "national_bank_of_romania": https://www.bnr.ro/Exchange-rates-1224.aspx
|
# "national_bank_of_romania": https://www.bnr.ro/Exchange-rates-1224.aspx
|
||||||
# "bank_of_russia": https://www.cbr.ru/eng/currency_base/daily/
|
# "bank_of_russia": https://www.cbr.ru/eng/currency_base/daily/
|
||||||
# "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates
|
# "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/
|
# "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
|
data_source = euro_central_bank
|
||||||
|
|
||||||
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds)
|
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds)
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"code": [
|
||||||
|
"jiangshengwu",
|
||||||
|
"vigdail",
|
||||||
|
"f97",
|
||||||
|
"Miguelonlonlon",
|
||||||
|
"seb26",
|
||||||
|
"nktlitvinenko",
|
||||||
|
"lvdou-bing",
|
||||||
|
"dshemin",
|
||||||
|
"lucdsouza",
|
||||||
|
"OuIChien",
|
||||||
|
"RasterCrow"
|
||||||
|
],
|
||||||
|
"translators": {
|
||||||
|
"de": [
|
||||||
|
"chrgm"
|
||||||
|
],
|
||||||
|
"en": [],
|
||||||
|
"es": [
|
||||||
|
"Miguelonlonlon",
|
||||||
|
"abrugues",
|
||||||
|
"AndresTeller",
|
||||||
|
"diegofercri"
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"brieucdlf"
|
||||||
|
],
|
||||||
|
"it": [
|
||||||
|
"waron97"
|
||||||
|
],
|
||||||
|
"ja": [
|
||||||
|
"tkymmm"
|
||||||
|
],
|
||||||
|
"kn": [
|
||||||
|
"Darshanbm05"
|
||||||
|
],
|
||||||
|
"ko": [
|
||||||
|
"overworks"
|
||||||
|
],
|
||||||
|
"nl": [
|
||||||
|
"automagics"
|
||||||
|
],
|
||||||
|
"pt-BR": [
|
||||||
|
"thecodergus",
|
||||||
|
"balaios"
|
||||||
|
],
|
||||||
|
"ru": [
|
||||||
|
"artegoser",
|
||||||
|
"dshemin"
|
||||||
|
],
|
||||||
|
"sl": [
|
||||||
|
"thehijacker"
|
||||||
|
],
|
||||||
|
"ta": [
|
||||||
|
"hhharsha36"
|
||||||
|
],
|
||||||
|
"th": [
|
||||||
|
"natthavat28"
|
||||||
|
],
|
||||||
|
"tr": [
|
||||||
|
"aydnykn"
|
||||||
|
],
|
||||||
|
"uk": [
|
||||||
|
"nktlitvinenko"
|
||||||
|
],
|
||||||
|
"vi": [
|
||||||
|
"f97"
|
||||||
|
],
|
||||||
|
"zh-Hans": [],
|
||||||
|
"zh-Hant": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=ezBookkeeping, a lightweight personal bookkeeping app hosted by yourself.
|
Description=ezBookkeeping, a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.
|
||||||
After=syslog.target
|
After=syslog.target
|
||||||
After=network.target
|
After=network.target
|
||||||
After=mariadb.service mysqld.service postgresql.service
|
After=mariadb.service mysqld.service postgresql.service
|
||||||
|
|||||||
+9
-7
@@ -1,15 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/cmd"
|
"github.com/mayswind/ezbookkeeping/cmd"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,12 +26,13 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
settings.Version = Version
|
core.Version = Version
|
||||||
settings.CommitHash = CommitHash
|
core.CommitHash = CommitHash
|
||||||
|
core.BuildTime = BuildUnixTime
|
||||||
|
|
||||||
app := &cli.App{
|
cmd := &cli.Command{
|
||||||
Name: "ezBookkeeping",
|
Name: "ezBookkeeping",
|
||||||
Usage: "A lightweight personal bookkeeping app hosted by yourself.",
|
Usage: "A lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.",
|
||||||
Version: GetFullVersion(),
|
Version: GetFullVersion(),
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
cmd.WebServer,
|
cmd.WebServer,
|
||||||
@@ -52,7 +54,7 @@ func main() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := app.Run(os.Args)
|
err := cmd.Run(context.Background(), os.Args)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to run ezBookkeeping with %s: %v", os.Args, err)
|
log.Fatalf("Failed to run ezBookkeeping with %s: %v", os.Args, err)
|
||||||
|
|||||||
@@ -1,86 +1,110 @@
|
|||||||
module github.com/mayswind/ezbookkeeping
|
module github.com/mayswind/ezbookkeeping
|
||||||
|
|
||||||
go 1.24
|
go 1.25
|
||||||
|
|
||||||
require (
|
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/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||||
github.com/gin-contrib/cache v1.3.1
|
github.com/gin-contrib/cache v1.4.1
|
||||||
github.com/gin-contrib/gzip v1.2.2
|
github.com/gin-contrib/gzip v1.2.5
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0
|
github.com/go-co-op/gocron/v2 v2.19.1
|
||||||
github.com/go-playground/validator/v10 v10.24.0
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
github.com/go-sql-driver/mysql v1.8.1
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/invopop/jsonschema v0.13.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/lib/pq v1.11.1
|
||||||
github.com/minio/minio-go/v7 v7.0.85
|
github.com/mattn/go-sqlite3 v1.14.33
|
||||||
|
github.com/minio/minio-go/v7 v7.0.98
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
github.com/pquerna/otp v1.4.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/urfave/cli/v2 v2.27.5
|
github.com/urfave/cli/v3 v3.6.2
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||||
golang.org/x/crypto v0.33.0
|
github.com/xuri/excelize/v2 v2.10.0
|
||||||
golang.org/x/net v0.34.0
|
golang.org/x/crypto v0.47.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/net v0.49.0
|
||||||
gopkg.in/ini.v1 v1.67.0
|
golang.org/x/oauth2 v0.34.0
|
||||||
|
golang.org/x/text v0.33.0
|
||||||
|
gopkg.in/ini.v1 v1.67.1
|
||||||
gopkg.in/mail.v2 v2.3.1
|
gopkg.in/mail.v2 v2.3.1
|
||||||
xorm.io/builder v0.3.13
|
xorm.io/builder v0.3.13
|
||||||
xorm.io/xorm v1.3.9
|
xorm.io/xorm v1.3.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // indirect
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
github.com/bytedance/sonic v1.12.7 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.2 // 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/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
||||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-ini/ini v1.67.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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.4 // 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/golang/snappy v0.0.4 // indirect
|
||||||
github.com/gomodule/redigo v1.9.2 // indirect
|
github.com/gomodule/redigo v1.9.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jonboulle/clockwork v0.4.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.17.11 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||||
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/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
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // 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/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
golang.org/x/arch v0.13.0 // indirect
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.2 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,28 +4,32 @@ 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 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
|
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||||
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
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 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
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.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.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||||
github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
|
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||||
github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I=
|
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
github.com/bytedance/sonic/loader v0.2.2 h1:jxAJuN9fOot/cyz5Q6dUuMJF5OqQ6+5GfA8FjjQ0R4o=
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/bytedance/sonic/loader v0.2.2/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
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.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
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/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -39,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 h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
|
||||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/cache v1.3.1 h1:EWjkOaLocs5fGt9feQaI7rt1GZbDyatFXEUh2/s3ZI8=
|
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
|
||||||
github.com/gin-contrib/cache v1.3.1/go.mod h1:6Tme0p3QEF/Ck/KUcq7h/OAqZvUDjHRH1DtQbNgfIX0=
|
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
|
||||||
github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI=
|
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||||
github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s=
|
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
|
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
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-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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
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.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
@@ -75,63 +83,80 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
|
|||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
|
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||||
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
|
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||||
|
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||||
|
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.33/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 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||||
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
|
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
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/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo=
|
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||||
github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
|
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
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.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
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=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
||||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -142,50 +167,67 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
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 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
||||||
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
|
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
|
||||||
|
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.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||||
|
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
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/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.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
|
||||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
github.com/urfave/cli/v3 v3.6.2/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 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/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.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=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
|
||||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
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.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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
|
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 h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
@@ -197,5 +239,5 @@ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYm
|
|||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
||||||
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||||
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU=
|
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
|
||||||
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
|
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { type JestConfigWithTsJest, createDefaultEsmPreset } from 'ts-jest';
|
||||||
|
|
||||||
|
const presetConfig = createDefaultEsmPreset({
|
||||||
|
tsconfig: '<rootDir>/tsconfig.jest.json'
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: JestConfigWithTsJest = {
|
||||||
|
...presetConfig,
|
||||||
|
clearMocks: true,
|
||||||
|
collectCoverage: false,
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@/(.*)$": "<rootDir>/src/$1"
|
||||||
|
},
|
||||||
|
testEnvironment: "node",
|
||||||
|
testMatch: [
|
||||||
|
"**/__tests__/**/*.[jt]s?(x)",
|
||||||
|
"!**/__tests__/*_gen.[jt]s?(x)"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Generated
+7348
-2595
File diff suppressed because it is too large
Load Diff
+47
-32
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ezbookkeeping",
|
"name": "ezbookkeeping",
|
||||||
"version": "0.8.0",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -15,62 +15,77 @@
|
|||||||
"serve": "cross-env NODE_ENV=development vite",
|
"serve": "cross-env NODE_ENV=development vite",
|
||||||
"build": "cross-env NODE_ENV=production vite build",
|
"build": "cross-env NODE_ENV=production vite build",
|
||||||
"serve:dist": "vite preview",
|
"serve:dist": "vite preview",
|
||||||
"lint": "tsc --noEmit && eslint . --fix"
|
"lint": "vue-tsc --noEmit && eslint . --fix",
|
||||||
|
"test": "cross-env TS_NODE_PROJECT=\"./tsconfig.jest.json\" jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@vuepic/vue-datepicker": "^11.0.1",
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.13.4",
|
||||||
"cbor-js": "^0.1.0",
|
"cbor-js": "^0.1.0",
|
||||||
|
"chardet": "^2.1.1",
|
||||||
"clipboard": "^2.0.11",
|
"clipboard": "^2.0.11",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dom7": "^4.0.6",
|
"dom7": "^4.0.6",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^6.0.0",
|
||||||
"framework7": "^8.3.4",
|
"framework7": "^9.0.3",
|
||||||
"framework7-icons": "^5.0.5",
|
"framework7-icons": "^5.0.5",
|
||||||
"framework7-vue": "^8.3.4",
|
"framework7-vue": "^9.0.3",
|
||||||
|
"jalaali-js": "^1.2.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"line-awesome": "^1.3.0",
|
"line-awesome": "^1.3.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"moment-timezone": "^0.5.47",
|
"moment-timezone": "^0.6.0",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^3.0.4",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"skeleton-elements": "^4.0.1",
|
"skeleton-elements": "^4.0.1",
|
||||||
"swiper": "^10.2.0",
|
"swiper": "^12.1.0",
|
||||||
"ua-parser-js": "^1.0.39",
|
"ua-parser-js": "^1.0.39",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.27",
|
||||||
"vue-echarts": "^7.0.3",
|
"vue-echarts": "^8.0.1",
|
||||||
"vue-i18n": "^11.1.1",
|
"vue-i18n": "^11.2.8",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^5.0.2",
|
||||||
"vue3-perfect-scrollbar": "^2.0.0",
|
"vue3-perfect-scrollbar": "^2.0.0",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
"vuetify": "^3.7.11"
|
"vuetify": "^3.11.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "^22.0.0",
|
"@jest/globals": "^30.2.0",
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
"@types/cbor-js": "^0.1.1",
|
"@types/cbor-js": "^0.1.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/git-rev-sync": "^2.0.2",
|
"@types/git-rev-sync": "^2.0.2",
|
||||||
"@types/node": "^22.12.0",
|
"@types/jalaali-js": "^1.2.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.1.0",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9.20.0",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"eslint-plugin-vue": "^10.7.0",
|
||||||
"git-rev-sync": "^3.0.2",
|
"git-rev-sync": "^3.0.2",
|
||||||
"postcss-preset-env": "^10.1.3",
|
"jest": "^30.2.0",
|
||||||
"sass": "^1.84.0",
|
"postcss-preset-env": "^11.1.3",
|
||||||
"typescript": "^5.7.3",
|
"sass": "^1.97.3",
|
||||||
"vite": "^6.1.0",
|
"ts-jest": "^29.4.6",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"ts-node": "^10.9.2",
|
||||||
"vite-plugin-vuetify": "^2.1.0",
|
"typescript": "^5.9.3",
|
||||||
"vue-tsc": "^2.2.0"
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-checker": "^0.12.0",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vite-plugin-vuetify": "^2.1.3",
|
||||||
|
"vue-tsc": "^3.2.4"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 1%",
|
"last 5 Chrome versions",
|
||||||
"last 2 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"
|
"not dead"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+276
-33
@@ -150,10 +150,10 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
utcOffset, err := c.GetClientTimezoneOffset()
|
clientTimezone, err := c.GetClientTimezone()
|
||||||
|
|
||||||
if err != nil {
|
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
|
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 {
|
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())
|
log.Errorf(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
|
||||||
@@ -287,7 +287,7 @@ func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
|
|
||||||
log.Infof(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
|
log.Infof(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
|
||||||
|
|
||||||
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
|
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
|
||||||
accountInfoResp := mainAccount.ToAccountInfoResponse()
|
accountInfoResp := mainAccount.ToAccountInfoResponse()
|
||||||
|
|
||||||
if len(childrenAccounts) > 0 {
|
if len(childrenAccounts) > 0 {
|
||||||
@@ -311,11 +311,27 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if accountModifyReq.Id <= 0 {
|
||||||
|
return nil, errs.ErrAccountIdInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
clientTimezone, err := c.GetClientTimezone()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[accounts.AccountModifyHandler] cannot get client timezone, because %s", err.Error())
|
||||||
|
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||||
|
}
|
||||||
|
|
||||||
if accountModifyReq.Category < models.ACCOUNT_CATEGORY_CASH || accountModifyReq.Category > models.ACCOUNT_CATEGORY_CERTIFICATE_OF_DEPOSIT {
|
if accountModifyReq.Category < models.ACCOUNT_CATEGORY_CASH || accountModifyReq.Category > models.ACCOUNT_CATEGORY_CERTIFICATE_OF_DEPOSIT {
|
||||||
log.Warnf(c, "[accounts.AccountModifyHandler] account category invalid, category is %d", accountModifyReq.Category)
|
log.Warnf(c, "[accounts.AccountModifyHandler] account category invalid, category is %d", accountModifyReq.Category)
|
||||||
return nil, errs.ErrAccountCategoryInvalid
|
return nil, errs.ErrAccountCategoryInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if accountModifyReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountModifyReq.CreditCardStatementDate != 0 {
|
||||||
|
log.Warnf(c, "[accounts.AccountModifyHandler] cannot set statement date with category \"%d\"", accountModifyReq.Category)
|
||||||
|
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
|
||||||
|
}
|
||||||
|
|
||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
|
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
|
||||||
|
|
||||||
@@ -331,20 +347,81 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
return nil, errs.ErrAccountNotFound
|
return nil, errs.ErrAccountNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(accountModifyReq.SubAccounts)+1 != len(accountAndSubAccounts) {
|
if accountModifyReq.Currency != nil && mainAccount.Currency != *accountModifyReq.Currency {
|
||||||
return nil, errs.ErrCannotAddOrDeleteSubAccountsWhenModify
|
return nil, errs.ErrNotSupportedChangeCurrency
|
||||||
}
|
}
|
||||||
|
|
||||||
if accountModifyReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountModifyReq.CreditCardStatementDate != 0 {
|
if accountModifyReq.Balance != nil {
|
||||||
log.Warnf(c, "[accounts.AccountModifyHandler] cannot set statement date with category \"%d\"", accountModifyReq.Category)
|
return nil, errs.ErrNotSupportedChangeBalance
|
||||||
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if mainAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
if accountModifyReq.BalanceTime != nil {
|
||||||
|
return nil, errs.ErrNotSupportedChangeBalanceTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if mainAccount.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||||
|
if len(accountModifyReq.SubAccounts) > 0 {
|
||||||
|
log.Warnf(c, "[accounts.AccountModifyHandler] account cannot have any sub-accounts")
|
||||||
|
return nil, errs.ErrAccountCannotHaveSubAccounts
|
||||||
|
}
|
||||||
|
} else if mainAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||||
|
if len(accountModifyReq.SubAccounts) < 1 {
|
||||||
|
log.Warnf(c, "[accounts.AccountModifyHandler] account does not have any sub-accounts")
|
||||||
|
return nil, errs.ErrAccountHaveNoSubAccount
|
||||||
|
}
|
||||||
|
|
||||||
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
||||||
subAccount := accountModifyReq.SubAccounts[i]
|
subAccountReq := accountModifyReq.SubAccounts[i]
|
||||||
|
|
||||||
if subAccount.CreditCardStatementDate != 0 {
|
if subAccountReq.Category != accountModifyReq.Category {
|
||||||
|
log.Warnf(c, "[accounts.AccountModifyHandler] category of sub-account#%d not equals to parent", i)
|
||||||
|
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
|
||||||
|
}
|
||||||
|
|
||||||
|
if subAccountReq.Id == 0 { // create new sub-account
|
||||||
|
if subAccountReq.Currency == nil {
|
||||||
|
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d not set currency", i)
|
||||||
|
return nil, errs.ErrAccountCurrencyInvalid
|
||||||
|
} else if subAccountReq.Currency != nil && *subAccountReq.Currency == validators.ParentAccountCurrencyPlaceholder {
|
||||||
|
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set currency placeholder", i)
|
||||||
|
return nil, errs.ErrAccountCurrencyInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if subAccountReq.Balance == nil {
|
||||||
|
defaultBalance := int64(0)
|
||||||
|
subAccountReq.Balance = &defaultBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
if *subAccountReq.Balance == 0 {
|
||||||
|
defaultBalanceTime := int64(0)
|
||||||
|
subAccountReq.BalanceTime = &defaultBalanceTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if *subAccountReq.Balance != 0 && (subAccountReq.BalanceTime == nil || *subAccountReq.BalanceTime <= 0) {
|
||||||
|
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d balance time is not set", i)
|
||||||
|
return nil, errs.ErrAccountBalanceTimeNotSet
|
||||||
|
}
|
||||||
|
} else { // modify existed sub-account
|
||||||
|
subAccount, exists := accountMap[subAccountReq.Id]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, errs.ErrAccountNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if subAccountReq.Currency != nil && subAccount.Currency != *subAccountReq.Currency {
|
||||||
|
return nil, errs.ErrNotSupportedChangeCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
if subAccountReq.Balance != nil {
|
||||||
|
return nil, errs.ErrNotSupportedChangeBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
if subAccountReq.BalanceTime != nil {
|
||||||
|
return nil, errs.ErrNotSupportedChangeBalanceTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subAccountReq.CreditCardStatementDate != 0 {
|
||||||
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set statement date", i)
|
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set statement date", i)
|
||||||
return nil, errs.ErrCannotSetStatementDateForSubAccount
|
return nil, errs.ErrCannotSetStatementDateForSubAccount
|
||||||
}
|
}
|
||||||
@@ -353,26 +430,65 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
|
|
||||||
anythingUpdate := false
|
anythingUpdate := false
|
||||||
var toUpdateAccounts []*models.Account
|
var toUpdateAccounts []*models.Account
|
||||||
|
var toAddAccounts []*models.Account
|
||||||
|
var toAddAccountBalanceTimes []int64
|
||||||
|
var toDeleteAccountIds []int64
|
||||||
|
|
||||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
|
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
|
||||||
|
|
||||||
if toUpdateAccount != nil {
|
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
|
anythingUpdate = true
|
||||||
toUpdateAccounts = append(toUpdateAccounts, toUpdateAccount)
|
toUpdateAccounts = append(toUpdateAccounts, toUpdateAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toDeleteAccountIds = a.getToDeleteSubAccountIds(&accountModifyReq, mainAccount, accountAndSubAccounts)
|
||||||
|
|
||||||
|
if len(toDeleteAccountIds) > 0 {
|
||||||
|
anythingUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
maxOrderId := int32(0)
|
||||||
|
|
||||||
|
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||||
|
account := accountAndSubAccounts[i]
|
||||||
|
|
||||||
|
if account.AccountId != mainAccount.AccountId && account.DisplayOrder > maxOrderId {
|
||||||
|
maxOrderId = account.DisplayOrder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
||||||
subAccountReq := accountModifyReq.SubAccounts[i]
|
subAccountReq := accountModifyReq.SubAccounts[i]
|
||||||
|
|
||||||
if _, exists := accountMap[subAccountReq.Id]; !exists {
|
if _, exists := accountMap[subAccountReq.Id]; !exists {
|
||||||
return nil, errs.ErrAccountNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
|
|
||||||
|
|
||||||
if toUpdateSubAccount != nil {
|
|
||||||
anythingUpdate = true
|
anythingUpdate = true
|
||||||
toUpdateAccounts = append(toUpdateAccounts, toUpdateSubAccount)
|
maxOrderId = maxOrderId + 1
|
||||||
|
newSubAccount := a.createNewSubAccountModelForModify(uid, mainAccount.Type, subAccountReq, maxOrderId)
|
||||||
|
toAddAccounts = append(toAddAccounts, newSubAccount)
|
||||||
|
|
||||||
|
if subAccountReq.BalanceTime != nil {
|
||||||
|
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, *subAccountReq.BalanceTime)
|
||||||
|
} else {
|
||||||
|
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
|
||||||
|
|
||||||
|
if toUpdateSubAccount != nil {
|
||||||
|
anythingUpdate = true
|
||||||
|
toUpdateAccounts = append(toUpdateAccounts, toUpdateSubAccount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +496,43 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
return nil, errs.ErrNothingWillBeUpdated
|
return nil, errs.ErrNothingWillBeUpdated
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.accounts.ModifyAccounts(c, uid, toUpdateAccounts)
|
if len(toAddAccounts) > 0 && a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountModifyReq.ClientSessionId != "" {
|
||||||
|
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_SUBACCOUNT, uid, accountModifyReq.ClientSessionId)
|
||||||
|
|
||||||
|
if found {
|
||||||
|
log.Infof(c, "[accounts.AccountModifyHandler] another account \"id:%s\" modification has been created for user \"uid:%d\"", remark, uid)
|
||||||
|
accountId, err := utils.StringToInt64(remark)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
|
||||||
|
mainAccount, exists := accountMap[accountId]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
accountInfoResp := mainAccount.ToAccountInfoResponse()
|
||||||
|
|
||||||
|
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||||
|
if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId {
|
||||||
|
subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse()
|
||||||
|
accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountInfoResp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.accounts.ModifyAccounts(c, mainAccount, toUpdateAccounts, toAddAccounts, toAddAccountBalanceTimes, toDeleteAccountIds, clientTimezone)
|
||||||
|
|
||||||
if err != nil {
|
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())
|
log.Errorf(c, "[accounts.AccountModifyHandler] failed to update account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
|
||||||
@@ -389,6 +541,10 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
|
|
||||||
log.Infof(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
|
log.Infof(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
|
||||||
|
|
||||||
|
if len(toAddAccounts) > 0 {
|
||||||
|
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_SUBACCOUNT, uid, accountModifyReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
|
||||||
|
}
|
||||||
|
|
||||||
accountRespMap := make(map[int64]*models.AccountInfoResponse)
|
accountRespMap := make(map[int64]*models.AccountInfoResponse)
|
||||||
|
|
||||||
for i := 0; i < len(toUpdateAccounts); i++ {
|
for i := 0; i < len(toUpdateAccounts); i++ {
|
||||||
@@ -397,7 +553,6 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
|
|
||||||
account.Type = oldAccount.Type
|
account.Type = oldAccount.Type
|
||||||
account.ParentAccountId = oldAccount.ParentAccountId
|
account.ParentAccountId = oldAccount.ParentAccountId
|
||||||
account.DisplayOrder = oldAccount.DisplayOrder
|
|
||||||
account.Currency = oldAccount.Currency
|
account.Currency = oldAccount.Currency
|
||||||
account.Balance = oldAccount.Balance
|
account.Balance = oldAccount.Balance
|
||||||
|
|
||||||
@@ -405,11 +560,23 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
accountRespMap[accountResp.Id] = accountResp
|
accountRespMap[accountResp.Id] = accountResp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(toAddAccounts); i++ {
|
||||||
|
account := toAddAccounts[i]
|
||||||
|
accountResp := account.ToAccountInfoResponse()
|
||||||
|
accountRespMap[accountResp.Id] = accountResp
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedAccountIds := make(map[int64]bool)
|
||||||
|
|
||||||
|
for i := 0; i < len(toDeleteAccountIds); i++ {
|
||||||
|
deletedAccountIds[toDeleteAccountIds[i]] = true
|
||||||
|
}
|
||||||
|
|
||||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||||
oldAccount := accountAndSubAccounts[i]
|
oldAccount := accountAndSubAccounts[i]
|
||||||
_, exists := accountRespMap[oldAccount.AccountId]
|
_, exists := accountRespMap[oldAccount.AccountId]
|
||||||
|
|
||||||
if !exists {
|
if !exists && !deletedAccountIds[oldAccount.AccountId] {
|
||||||
oldAccountResp := oldAccount.ToAccountInfoResponse()
|
oldAccountResp := oldAccount.ToAccountInfoResponse()
|
||||||
accountRespMap[oldAccountResp.Id] = oldAccountResp
|
accountRespMap[oldAccountResp.Id] = oldAccountResp
|
||||||
}
|
}
|
||||||
@@ -418,8 +585,19 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
accountResp := accountRespMap[accountModifyReq.Id]
|
accountResp := accountRespMap[accountModifyReq.Id]
|
||||||
|
|
||||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||||
if accountAndSubAccounts[i].ParentAccountId == accountResp.Id {
|
account := accountAndSubAccounts[i]
|
||||||
subAccountResp := accountRespMap[accountAndSubAccounts[i].AccountId]
|
|
||||||
|
if account.ParentAccountId == accountResp.Id && !deletedAccountIds[account.AccountId] {
|
||||||
|
subAccountResp := accountRespMap[account.AccountId]
|
||||||
|
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(toAddAccounts); i++ {
|
||||||
|
account := toAddAccounts[i]
|
||||||
|
|
||||||
|
if account.ParentAccountId == accountResp.Id {
|
||||||
|
subAccountResp := accountRespMap[account.AccountId]
|
||||||
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
|
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -508,6 +686,28 @@ func (a *AccountsApi) AccountDeleteHandler(c *core.WebContext) (any, *errs.Error
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubAccountDeleteHandler deletes an existed sub-account by request parameters for current user
|
||||||
|
func (a *AccountsApi) SubAccountDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
var accountDeleteReq models.AccountDeleteRequest
|
||||||
|
err := c.ShouldBindJSON(&accountDeleteReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[accounts.SubAccountDeleteHandler] parse request failed, because %s", err.Error())
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
err = a.accounts.DeleteSubAccount(c, uid, accountDeleteReq.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[accounts.SubAccountDeleteHandler] failed to delete sub-account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(c, "[accounts.SubAccountDeleteHandler] user \"uid:%d\" has deleted sub-account \"id:%d\"", uid, accountDeleteReq.Id)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, isSubAccount bool, order int32) *models.Account {
|
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, isSubAccount bool, order int32) *models.Account {
|
||||||
accountExtend := &models.AccountExtend{}
|
accountExtend := &models.AccountExtend{}
|
||||||
|
|
||||||
@@ -530,6 +730,24 @@ func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *AccountsApi) createNewSubAccountModelForModify(uid int64, accountType models.AccountType, accountModifyReq *models.AccountModifyRequest, order int32) *models.Account {
|
||||||
|
accountExtend := &models.AccountExtend{}
|
||||||
|
|
||||||
|
return &models.Account{
|
||||||
|
Uid: uid,
|
||||||
|
Name: accountModifyReq.Name,
|
||||||
|
DisplayOrder: order,
|
||||||
|
Category: accountModifyReq.Category,
|
||||||
|
Type: accountType,
|
||||||
|
Icon: accountModifyReq.Icon,
|
||||||
|
Color: accountModifyReq.Color,
|
||||||
|
Currency: *accountModifyReq.Currency,
|
||||||
|
Balance: *accountModifyReq.Balance,
|
||||||
|
Comment: accountModifyReq.Comment,
|
||||||
|
Extend: accountExtend,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) ([]*models.Account, []int64) {
|
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) ([]*models.Account, []int64) {
|
||||||
if len(accountCreateReq.SubAccounts) <= 0 {
|
if len(accountCreateReq.SubAccounts) <= 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -554,15 +772,16 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
|||||||
}
|
}
|
||||||
|
|
||||||
newAccount := &models.Account{
|
newAccount := &models.Account{
|
||||||
AccountId: oldAccount.AccountId,
|
AccountId: oldAccount.AccountId,
|
||||||
Uid: uid,
|
Uid: uid,
|
||||||
Name: accountModifyReq.Name,
|
Name: accountModifyReq.Name,
|
||||||
Category: accountModifyReq.Category,
|
DisplayOrder: oldAccount.DisplayOrder,
|
||||||
Icon: accountModifyReq.Icon,
|
Category: accountModifyReq.Category,
|
||||||
Color: accountModifyReq.Color,
|
Icon: accountModifyReq.Icon,
|
||||||
Comment: accountModifyReq.Comment,
|
Color: accountModifyReq.Color,
|
||||||
Extend: newAccountExtend,
|
Comment: accountModifyReq.Comment,
|
||||||
Hidden: accountModifyReq.Hidden,
|
Extend: newAccountExtend,
|
||||||
|
Hidden: accountModifyReq.Hidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
if newAccount.Name != oldAccount.Name ||
|
if newAccount.Name != oldAccount.Name ||
|
||||||
@@ -587,3 +806,27 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *AccountsApi) getToDeleteSubAccountIds(accountModifyReq *models.AccountModifyRequest, mainAccount *models.Account, accountAndSubAccounts []*models.Account) []int64 {
|
||||||
|
newSubAccountIds := make(map[int64]bool, len(accountModifyReq.SubAccounts))
|
||||||
|
|
||||||
|
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
|
||||||
|
newSubAccountIds[accountModifyReq.SubAccounts[i].Id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
toDeleteAccountIds := make([]int64, 0)
|
||||||
|
|
||||||
|
for i := 0; i < len(accountAndSubAccounts); i++ {
|
||||||
|
subAccount := accountAndSubAccounts[i]
|
||||||
|
|
||||||
|
if subAccount.AccountId == mainAccount.AccountId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := newSubAccountIds[subAccount.AccountId]; !exists {
|
||||||
|
toDeleteAccountIds = append(toDeleteAccountIds, subAccount.AccountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toDeleteAccountIds
|
||||||
|
}
|
||||||
|
|||||||
+239
-11
@@ -1,6 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||||
@@ -19,8 +22,10 @@ type AuthorizationsApi struct {
|
|||||||
ApiUsingDuplicateChecker
|
ApiUsingDuplicateChecker
|
||||||
ApiWithUserInfo
|
ApiWithUserInfo
|
||||||
users *services.UserService
|
users *services.UserService
|
||||||
|
userAppCloudSettings *services.UserApplicationCloudSettingsService
|
||||||
tokens *services.TokenService
|
tokens *services.TokenService
|
||||||
twoFactorAuthorizations *services.TwoFactorAuthorizationService
|
twoFactorAuthorizations *services.TwoFactorAuthorizationService
|
||||||
|
userExternalAuths *services.UserExternalAuthService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a authorization api singleton instance
|
// Initialize a authorization api singleton instance
|
||||||
@@ -44,13 +49,19 @@ var (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
users: services.Users,
|
users: services.Users,
|
||||||
|
userAppCloudSettings: services.UserApplicationCloudSettings,
|
||||||
tokens: services.Tokens,
|
tokens: services.Tokens,
|
||||||
twoFactorAuthorizations: services.TwoFactorAuthorizations,
|
twoFactorAuthorizations: services.TwoFactorAuthorizations,
|
||||||
|
userExternalAuths: services.UserExternalAuths,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthorizeHandler verifies and authorizes current login request
|
// AuthorizeHandler verifies and authorizes current login request
|
||||||
func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
if !a.CurrentConfig().EnableInternalAuth {
|
||||||
|
return nil, errs.ErrCannotLoginByPassword
|
||||||
|
}
|
||||||
|
|
||||||
var credential models.UserLoginRequest
|
var credential models.UserLoginRequest
|
||||||
err := c.ShouldBindJSON(&credential)
|
err := c.ShouldBindJSON(&credential)
|
||||||
|
|
||||||
@@ -139,15 +150,29 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.SetTokenClaims(claims)
|
c.SetTokenClaims(claims)
|
||||||
|
c.SetTokenContext("")
|
||||||
|
|
||||||
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)
|
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||||
|
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||||
|
|
||||||
authResp := a.getAuthResponse(c, token, twoFactorEnable, user)
|
if err != nil {
|
||||||
|
log.Warnf(c, "[authorizations.AuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||||
|
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||||
|
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has 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
|
return authResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
|
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
|
||||||
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
if !a.CurrentConfig().EnableInternalAuth {
|
||||||
|
return nil, errs.ErrCannotLoginByPassword
|
||||||
|
}
|
||||||
|
|
||||||
var credential models.TwoFactorLoginRequest
|
var credential models.TwoFactorLoginRequest
|
||||||
err := c.ShouldBindJSON(&credential)
|
err := c.ShouldBindJSON(&credential)
|
||||||
|
|
||||||
@@ -187,7 +212,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
|||||||
user, err := a.users.GetUserById(c, uid)
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
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
|
return nil, errs.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,15 +242,29 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
|||||||
|
|
||||||
c.SetTextualToken(token)
|
c.SetTextualToken(token)
|
||||||
c.SetTokenClaims(claims)
|
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.TwoFactorAuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||||
|
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||||
|
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two-factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
log.Infof(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two-factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||||
|
|
||||||
authResp := a.getAuthResponse(c, token, false, user)
|
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
|
||||||
return authResp, nil
|
return authResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
|
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
|
||||||
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
if !a.CurrentConfig().EnableInternalAuth {
|
||||||
|
return nil, errs.ErrCannotLoginByPassword
|
||||||
|
}
|
||||||
|
|
||||||
var credential models.TwoFactorRecoveryCodeLoginRequest
|
var credential models.TwoFactorRecoveryCodeLoginRequest
|
||||||
err := c.ShouldBindJSON(&credential)
|
err := c.ShouldBindJSON(&credential)
|
||||||
|
|
||||||
@@ -256,7 +295,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
|||||||
user, err := a.users.GetUserById(c, uid)
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
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
|
return nil, errs.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,18 +341,207 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
|||||||
|
|
||||||
c.SetTextualToken(token)
|
c.SetTextualToken(token)
|
||||||
c.SetTokenClaims(claims)
|
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.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||||
|
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||||
|
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two-factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt)
|
log.Infof(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two-factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt)
|
||||||
|
|
||||||
authResp := a.getAuthResponse(c, token, false, user)
|
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
|
||||||
return authResp, nil
|
return authResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User) *models.AuthResponse {
|
// 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{
|
return &models.AuthResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
Need2FA: need2FA,
|
Need2FA: need2FA,
|
||||||
User: a.GetUserBasicInfo(user),
|
User: a.GetUserBasicInfo(user),
|
||||||
NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()),
|
ApplicationCloudSettings: applicationCloudSettings,
|
||||||
|
NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-13
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
@@ -23,7 +24,7 @@ type ApiUsingConfig struct {
|
|||||||
|
|
||||||
// CurrentConfig returns the current config
|
// CurrentConfig returns the current config
|
||||||
func (a *ApiUsingConfig) CurrentConfig() *settings.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
|
// 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
|
language = clientLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
if !a.container.Current.AfterRegisterNotification.Enabled {
|
if !a.CurrentConfig().AfterRegisterNotification.Enabled {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if multiLanguageContent, exists := a.container.Current.AfterRegisterNotification.MultiLanguageContent[language]; exists {
|
if multiLanguageContent, exists := a.CurrentConfig().AfterRegisterNotification.MultiLanguageContent[language]; exists {
|
||||||
return multiLanguageContent
|
return multiLanguageContent
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.container.Current.AfterRegisterNotification.DefaultContent
|
return a.CurrentConfig().AfterRegisterNotification.DefaultContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
|
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
|
||||||
@@ -72,15 +73,15 @@ func (a *ApiUsingConfig) GetAfterLoginNotificationContent(userLanguage string, c
|
|||||||
language = clientLanguage
|
language = clientLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
if !a.container.Current.AfterLoginNotification.Enabled {
|
if !a.CurrentConfig().AfterLoginNotification.Enabled {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if multiLanguageContent, exists := a.container.Current.AfterLoginNotification.MultiLanguageContent[language]; exists {
|
if multiLanguageContent, exists := a.CurrentConfig().AfterLoginNotification.MultiLanguageContent[language]; exists {
|
||||||
return multiLanguageContent
|
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
|
// 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
|
language = clientLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
if !a.container.Current.AfterOpenNotification.Enabled {
|
if !a.CurrentConfig().AfterOpenNotification.Enabled {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if multiLanguageContent, exists := a.container.Current.AfterOpenNotification.MultiLanguageContent[language]; exists {
|
if multiLanguageContent, exists := a.CurrentConfig().AfterOpenNotification.MultiLanguageContent[language]; exists {
|
||||||
return multiLanguageContent
|
return multiLanguageContent
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.container.Current.AfterOpenNotification.DefaultContent
|
return a.CurrentConfig().AfterOpenNotification.DefaultContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
|
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
|
||||||
@@ -113,9 +114,28 @@ func (a *ApiUsingDuplicateChecker) GetSubmissionRemark(checkerType duplicatechec
|
|||||||
return a.container.GetSubmissionRemark(checkerType, uid, identification)
|
return a.container.GetSubmissionRemark(checkerType, uid, identification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSubmissionRemark saves the identification and remark to in-memory cache by the current duplicate checker
|
// SetSubmissionRemarkWithCustomExpiration saves the identification and remark by the current duplicate checker with custom expiration time
|
||||||
func (a *ApiUsingDuplicateChecker) SetSubmissionRemark(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string) {
|
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkWithCustomExpiration(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) {
|
||||||
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
|
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 {
|
||||||
|
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
a.container.RemoveSubmissionRemark(checkerType, uid, identification)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
|
// CheckFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
|
||||||
|
|||||||
+217
-42
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,19 +16,23 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pageCountForClearTransactions = 1000
|
||||||
const pageCountForDataExport = 1000
|
const pageCountForDataExport = 1000
|
||||||
|
|
||||||
// DataManagementsApi represents data management api
|
// DataManagementsApi represents data management api
|
||||||
type DataManagementsApi struct {
|
type DataManagementsApi struct {
|
||||||
ApiUsingConfig
|
ApiUsingConfig
|
||||||
tokens *services.TokenService
|
tokens *services.TokenService
|
||||||
users *services.UserService
|
users *services.UserService
|
||||||
accounts *services.AccountService
|
accounts *services.AccountService
|
||||||
transactions *services.TransactionService
|
transactions *services.TransactionService
|
||||||
categories *services.TransactionCategoryService
|
categories *services.TransactionCategoryService
|
||||||
tags *services.TransactionTagService
|
tags *services.TransactionTagService
|
||||||
pictures *services.TransactionPictureService
|
tagGroups *services.TransactionTagGroupService
|
||||||
templates *services.TransactionTemplateService
|
pictures *services.TransactionPictureService
|
||||||
|
templates *services.TransactionTemplateService
|
||||||
|
userCustomExchangeRates *services.UserCustomExchangeRatesService
|
||||||
|
insightsExploreres *services.InsightsExplorerService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a data management api singleton instance
|
// Initialize a data management api singleton instance
|
||||||
@@ -36,14 +41,17 @@ var (
|
|||||||
ApiUsingConfig: ApiUsingConfig{
|
ApiUsingConfig: ApiUsingConfig{
|
||||||
container: settings.Container,
|
container: settings.Container,
|
||||||
},
|
},
|
||||||
tokens: services.Tokens,
|
tokens: services.Tokens,
|
||||||
users: services.Users,
|
users: services.Users,
|
||||||
accounts: services.Accounts,
|
accounts: services.Accounts,
|
||||||
transactions: services.Transactions,
|
transactions: services.Transactions,
|
||||||
categories: services.TransactionCategories,
|
categories: services.TransactionCategories,
|
||||||
tags: services.TransactionTags,
|
tags: services.TransactionTags,
|
||||||
pictures: services.TransactionPictures,
|
tagGroups: services.TransactionTagGroups,
|
||||||
templates: services.TransactionTemplates,
|
pictures: services.TransactionPictures,
|
||||||
|
templates: services.TransactionTemplates,
|
||||||
|
userCustomExchangeRates: services.UserCustomExchangeRates,
|
||||||
|
insightsExploreres: services.InsightsExplorers,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,6 +103,13 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
|
|||||||
return nil, errs.ErrOperationFailed
|
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)
|
totalTransactionTemplateCount, err := a.templates.GetTotalNormalTemplateCountByUid(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,6 +130,7 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
|
|||||||
TotalTransactionTagCount: totalTransactionTagCount,
|
TotalTransactionTagCount: totalTransactionTagCount,
|
||||||
TotalTransactionCount: totalTransactionCount,
|
TotalTransactionCount: totalTransactionCount,
|
||||||
TotalTransactionPictureCount: totalTransactionPictureCount,
|
TotalTransactionPictureCount: totalTransactionPictureCount,
|
||||||
|
TotalInsightsExplorerCount: totalInsightsExplorerCount,
|
||||||
TotalTransactionTemplateCount: totalTransactionTemplateCount,
|
TotalTransactionTemplateCount: totalTransactionTemplateCount,
|
||||||
TotalScheduledTransactionCount: totalScheduledTransactionCount,
|
TotalScheduledTransactionCount: totalScheduledTransactionCount,
|
||||||
}
|
}
|
||||||
@@ -122,13 +138,13 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
|
|||||||
return dataStatisticsResp, nil
|
return dataStatisticsResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearDataHandler deletes all user data
|
// ClearAllDataHandler deletes all user data
|
||||||
func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *DataManagementsApi) ClearAllDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
var clearDataReq models.ClearDataRequest
|
var clearDataReq models.ClearDataRequest
|
||||||
err := c.ShouldBindJSON(&clearDataReq)
|
err := c.ShouldBindJSON(&clearDataReq)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +153,7 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errs.IsCustomError(err) {
|
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
|
return nil, errs.ErrUserNotFound
|
||||||
@@ -154,32 +170,148 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
err = a.templates.DeleteAllTemplates(c, uid)
|
err = a.templates.DeleteAllTemplates(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.transactions.DeleteAllTransactions(c, uid)
|
err = a.transactions.DeleteAllTransactions(c, uid, true)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.categories.DeleteAllCategories(c, uid)
|
err = a.categories.DeleteAllCategories(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.tags.DeleteAllTags(c, uid)
|
err = a.tags.DeleteAllTags(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
|
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.ClearAllDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,13 +320,19 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
|||||||
return nil, "", errs.ErrDataExportNotAllowed
|
return nil, "", errs.ErrDataExportNotAllowed
|
||||||
}
|
}
|
||||||
|
|
||||||
timezone := time.Local
|
var exportTransactionDataReq models.ExportTransactionDataRequest
|
||||||
utcOffset, err := c.GetClientTimezoneOffset()
|
err := c.ShouldBindQuery(&exportTransactionDataReq)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
|
log.Warnf(c, "[data_managements.getExportedFileContent] parse request failed, because %s", err.Error())
|
||||||
} else {
|
return nil, "", errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
timezone = time.FixedZone("Client Timezone", int(utcOffset)*60)
|
}
|
||||||
|
|
||||||
|
clientTimezone, err := c.GetClientTimezone()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[data_managements.getExportedFileContent] cannot get client timezone, because %s", err.Error())
|
||||||
|
clientTimezone = time.Local
|
||||||
}
|
}
|
||||||
|
|
||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
@@ -202,7 +340,7 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errs.IsCustomError(err) {
|
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
|
return nil, "", errs.ErrUserNotFound
|
||||||
@@ -215,28 +353,28 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
|||||||
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
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
|
return nil, "", errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
categories, err := a.categories.GetAllCategoriesByUid(c, uid, 0, -1)
|
categories, err := a.categories.GetAllCategoriesByUid(c, uid, 0, -1)
|
||||||
|
|
||||||
if err != nil {
|
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
|
return nil, "", errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
tags, err := a.tags.GetAllTagsByUid(c, uid)
|
tags, err := a.tags.GetAllTagsByUid(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
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
|
return nil, "", errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
tagIndexes, err := a.tags.GetAllTagIdsMapOfAllTransactions(c, uid)
|
tagIndexes, err := a.tags.GetAllTagIdsMapOfAllTransactions(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
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
|
return nil, "", errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,10 +382,47 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
|||||||
categoryMap := a.categories.GetCategoryMapByList(categories)
|
categoryMap := a.categories.GetCategoryMapByList(categories)
|
||||||
tagMap := a.tags.GetTagMapByList(tags)
|
tagMap := a.tags.GetTagMapByList(tags)
|
||||||
|
|
||||||
allTransactions, err := a.transactions.GetAllTransactions(c, uid, pageCountForDataExport, true)
|
allAccountIds, err := a.accounts.GetAccountOrSubAccountIds(c, exportTransactionDataReq.AccountIds, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, 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.getExportedFileContent] get transaction category error, because %s", err.Error())
|
||||||
|
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
noTags := exportTransactionDataReq.TagFilter == models.TransactionNoTagFilterValue
|
||||||
|
var tagFilters []*models.TransactionTagFilter
|
||||||
|
|
||||||
|
if !noTags {
|
||||||
|
tagFilters, err = models.ParseTransactionTagFilter(exportTransactionDataReq.TagFilter)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[data_managements.getExportedFileContent] parse transaction tag filters error, because %s", err.Error())
|
||||||
|
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTransactionTime := int64(math.MaxInt64)
|
||||||
|
minTransactionTime := int64(0)
|
||||||
|
|
||||||
|
if exportTransactionDataReq.MaxTime > 0 {
|
||||||
|
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(exportTransactionDataReq.MaxTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exportTransactionDataReq.MinTime > 0 {
|
||||||
|
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(exportTransactionDataReq.MinTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, pageCountForDataExport, true)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[data_managements.getExportedFileContent] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, "", errs.ErrOperationFailed
|
return nil, "", errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,17 +435,17 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
|||||||
result, err := dataExporter.ToExportedContent(c, uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes)
|
result, err := dataExporter.ToExportedContent(c, uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileName := a.getFileName(user, timezone, fileType)
|
fileName := a.getFileName(user, clientTimezone, fileType)
|
||||||
|
|
||||||
return result, fileName, nil
|
return result, fileName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *DataManagementsApi) getFileName(user *models.User, timezone *time.Location, fileExtension string) string {
|
func (a *DataManagementsApi) getFileName(user *models.User, clientTimezone *time.Location, fileExtension string) string {
|
||||||
currentTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(time.Now().Unix(), timezone)
|
currentTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(time.Now().Unix(), clientTimezone)
|
||||||
currentTime = strings.Replace(currentTime, "-", "_", -1)
|
currentTime = strings.Replace(currentTime, "-", "_", -1)
|
||||||
currentTime = strings.Replace(currentTime, " ", "_", -1)
|
currentTime = strings.Replace(currentTime, " ", "_", -1)
|
||||||
currentTime = strings.Replace(currentTime, ":", "_", -1)
|
currentTime = strings.Replace(currentTime, ":", "_", -1)
|
||||||
|
|||||||
+65
-95
@@ -1,25 +1,20 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExchangeRatesApi represents exchange rate api
|
// ExchangeRatesApi represents exchange rate api
|
||||||
type ExchangeRatesApi struct {
|
type ExchangeRatesApi struct {
|
||||||
ApiUsingConfig
|
ApiUsingConfig
|
||||||
|
users *services.UserService
|
||||||
|
userCustomExchangeRates *services.UserCustomExchangeRatesService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a exchange rate api singleton instance
|
// Initialize a exchange rate api singleton instance
|
||||||
@@ -28,109 +23,84 @@ var (
|
|||||||
ApiUsingConfig: ApiUsingConfig{
|
ApiUsingConfig: ApiUsingConfig{
|
||||||
container: settings.Container,
|
container: settings.Container,
|
||||||
},
|
},
|
||||||
|
users: services.Users,
|
||||||
|
userCustomExchangeRates: services.UserCustomExchangeRates,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// LatestExchangeRateHandler returns latest exchange rate data
|
// LatestExchangeRateHandler returns latest exchange rate data
|
||||||
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
dataSource := exchangerates.Container.Current
|
exchangeRateResponse, err := exchangerates.Container.GetLatestExchangeRates(c, c.GetCurrentUid(), a.CurrentConfig())
|
||||||
|
|
||||||
if dataSource == nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInvalidExchangeRatesDataSource
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return exchangeRateResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCustomExchangeRateUpdateHandler updates user custom exchange rates data by request parameters for current user
|
||||||
|
func (a *ExchangeRatesApi) UserCustomExchangeRateUpdateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
var customExchangeRateUpdateReq models.UserCustomExchangeRateUpdateRequest
|
||||||
|
err := c.ShouldBindJSON(&customExchangeRateUpdateReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] parse request failed, because %s", err.Error())
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
||||||
utils.SetProxyUrl(transport, a.CurrentConfig().ExchangeRatesProxy)
|
|
||||||
|
|
||||||
if a.CurrentConfig().ExchangeRatesSkipTLSVerify {
|
|
||||||
transport.TLSClientConfig = &tls.Config{
|
|
||||||
InsecureSkipVerify: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
Timeout: time.Duration(a.CurrentConfig().ExchangeRatesRequestTimeout) * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
requests, err := dataSource.BuildRequests()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to get user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.ErrFailedToRequestRemoteApi
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(requests))
|
if customExchangeRateUpdateReq.Currency == user.DefaultCurrency {
|
||||||
|
return nil, errs.ErrCannotUpdateExchangeRateForDefaultCurrency
|
||||||
for i := 0; i < len(requests); i++ {
|
|
||||||
req := requests[i]
|
|
||||||
|
|
||||||
if len(req.Header.Values("User-Agent")) < 1 {
|
|
||||||
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s", settings.Version))
|
|
||||||
} else if req.Header.Get("User-Agent") == "" {
|
|
||||||
req.Header.Del("User-Agent")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
|
|
||||||
return nil, errs.ErrFailedToRequestRemoteApi
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not 200", uid)
|
|
||||||
return nil, errs.ErrFailedToRequestRemoteApi
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
log.Debugf(c, "[exchange_rates.LatestExchangeRateHandler] response#%d is %s", i, body)
|
|
||||||
|
|
||||||
exchangeRateResp, err := dataSource.Parse(c, body)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(c, "[exchange_rates.LatestExchangeRateHandler] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
|
|
||||||
return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi)
|
|
||||||
}
|
|
||||||
|
|
||||||
exchangeRateResps = append(exchangeRateResps, exchangeRateResp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastExchangeRateResponse := exchangeRateResps[len(exchangeRateResps)-1]
|
newCustomExchangeRate, defaultCurrencyExchangeRate, err := a.userCustomExchangeRates.UpdateCustomExchangeRate(c, uid, customExchangeRateUpdateReq.Currency, customExchangeRateUpdateReq.Rate, user.DefaultCurrency)
|
||||||
allExchangeRatesMap := make(map[string]string)
|
|
||||||
|
|
||||||
for i := 0; i < len(exchangeRateResps); i++ {
|
if err != nil {
|
||||||
exchangeRateResp := exchangeRateResps[i]
|
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to update user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateUpdateReq.Currency, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
for j := 0; j < len(exchangeRateResp.ExchangeRates); j++ {
|
|
||||||
exchangeRate := exchangeRateResp.ExchangeRates[j]
|
|
||||||
allExchangeRatesMap[exchangeRate.Currency] = exchangeRate.Rate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
allExchangeRatesMap[lastExchangeRateResponse.BaseCurrency] = "1"
|
log.Infof(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] user \"uid:%d\" has updated user custom exchange rate \"currency:%s\" successfully", uid, customExchangeRateUpdateReq.Currency)
|
||||||
allExchangeRates := make(models.LatestExchangeRateSlice, 0, len(allExchangeRatesMap))
|
return newCustomExchangeRate.ToUserCustomExchangeRateUpdateResponse(defaultCurrencyExchangeRate.Rate), nil
|
||||||
|
}
|
||||||
for currency, rate := range allExchangeRatesMap {
|
|
||||||
allExchangeRates = append(allExchangeRates, &models.LatestExchangeRate{
|
// UserCustomExchangeRateDeleteHandler deletes an existed user custom exchange rates data by request parameters for current user
|
||||||
Currency: currency,
|
func (a *ExchangeRatesApi) UserCustomExchangeRateDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
Rate: rate,
|
var customExchangeRateDeleteReq models.UserCustomExchangeRateDeleteRequest
|
||||||
})
|
err := c.ShouldBindJSON(&customExchangeRateDeleteReq)
|
||||||
}
|
|
||||||
|
if err != nil {
|
||||||
sort.Sort(allExchangeRates)
|
log.Warnf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] parse request failed, because %s", err.Error())
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
finalExchangeRateResponse := &models.LatestExchangeRateResponse{
|
}
|
||||||
DataSource: lastExchangeRateResponse.DataSource,
|
|
||||||
ReferenceUrl: lastExchangeRateResponse.ReferenceUrl,
|
uid := c.GetCurrentUid()
|
||||||
UpdateTime: lastExchangeRateResponse.UpdateTime,
|
user, err := a.users.GetUserById(c, uid)
|
||||||
BaseCurrency: lastExchangeRateResponse.BaseCurrency,
|
|
||||||
ExchangeRates: allExchangeRates,
|
if err != nil {
|
||||||
}
|
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to get user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
return finalExchangeRateResponse, nil
|
}
|
||||||
|
|
||||||
|
if customExchangeRateDeleteReq.Currency == user.DefaultCurrency {
|
||||||
|
return nil, errs.ErrCannotDeleteExchangeRateForDefaultCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.userCustomExchangeRates.DeleteCustomExchangeRate(c, uid, customExchangeRateDeleteReq.Currency)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to delete user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateDeleteReq.Currency, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] user \"uid:%d\" has deleted user custom exchange rate \"currency:%s\"", uid, customExchangeRateDeleteReq.Currency)
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
// ForgetPasswordsApi represents user forget password api
|
// ForgetPasswordsApi represents user forget password api
|
||||||
type ForgetPasswordsApi struct {
|
type ForgetPasswordsApi struct {
|
||||||
ApiUsingConfig
|
ApiUsingConfig
|
||||||
|
ApiUsingDuplicateChecker
|
||||||
users *services.UserService
|
users *services.UserService
|
||||||
tokens *services.TokenService
|
tokens *services.TokenService
|
||||||
forgetPasswords *services.ForgetPasswordService
|
forgetPasswords *services.ForgetPasswordService
|
||||||
@@ -25,6 +27,12 @@ var (
|
|||||||
ApiUsingConfig: ApiUsingConfig{
|
ApiUsingConfig: ApiUsingConfig{
|
||||||
container: settings.Container,
|
container: settings.Container,
|
||||||
},
|
},
|
||||||
|
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||||
|
ApiUsingConfig: ApiUsingConfig{
|
||||||
|
container: settings.Container,
|
||||||
|
},
|
||||||
|
container: duplicatechecker.Container,
|
||||||
|
},
|
||||||
users: services.Users,
|
users: services.Users,
|
||||||
tokens: services.Tokens,
|
tokens: services.Tokens,
|
||||||
forgetPasswords: services.ForgetPasswords,
|
forgetPasswords: services.ForgetPasswords,
|
||||||
@@ -41,6 +49,13 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext
|
|||||||
return nil, errs.ErrEmailIsEmptyOrInvalid
|
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)
|
user, err := a.users.GetUserByEmail(c, request.Email)
|
||||||
|
|
||||||
if err != nil {
|
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())
|
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
|
return nil, errs.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +146,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any,
|
|||||||
|
|
||||||
if user.Email != request.Email {
|
if user.Email != request.Email {
|
||||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
|
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
|
||||||
return nil, errs.ErrEmptyIsInvalid
|
return nil, errs.ErrEmailIsInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.users.IsPasswordEqualsUserPassword(request.Password, user) {
|
if a.users.IsPasswordEqualsUserPassword(request.Password, user) {
|
||||||
|
|||||||
+2
-3
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HealthsApi represents health api
|
// HealthsApi represents health api
|
||||||
@@ -18,8 +17,8 @@ var (
|
|||||||
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
|
|
||||||
result["version"] = settings.Version
|
result["version"] = core.Version
|
||||||
result["commit"] = settings.CommitHash
|
result["commit"] = core.CommitHash
|
||||||
result["status"] = "ok"
|
result["status"] = "ok"
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import (
|
|||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/httpclient"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const openStreetMapTileImageUrlFormat = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" // https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
const 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
|
// MapImageProxy represents map image proxy
|
||||||
type MapImageProxy struct {
|
type MapImageProxy struct {
|
||||||
ApiUsingConfig
|
ApiUsingConfig
|
||||||
|
mutex sync.Mutex
|
||||||
|
transport *http.Transport
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a map image proxy singleton instance
|
// 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
|
// MapTileImageProxyHandler returns map tile image
|
||||||
func (p *MapImageProxy) MapTileImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
|
func (p *MapImageProxy) MapTileImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
|
||||||
return p.mapImageProxyHandler(c, func(c *core.WebContext, mapProvider string) (string, *errs.Error) {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
if p.transport == nil {
|
||||||
utils.SetProxyUrl(transport, p.CurrentConfig().MapProxy)
|
p.initializeHttpTransport()
|
||||||
|
}
|
||||||
|
|
||||||
director := func(req *http.Request) {
|
director := func(req *http.Request) {
|
||||||
imageRawUrl := targetUrl
|
imageRawUrl := targetUrl
|
||||||
@@ -126,7 +142,7 @@ func (p *MapImageProxy) mapImageProxyHandler(c *core.WebContext, fn func(c *core
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &httputil.ReverseProxy{
|
return &httputil.ReverseProxy{
|
||||||
Transport: transport,
|
Transport: p.transport,
|
||||||
Director: director,
|
Director: director,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/mcp"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mcpServerName = core.ApplicationName + "-mcp"
|
||||||
|
|
||||||
|
// ModelContextProtocolAPI represents model context protocol api
|
||||||
|
type ModelContextProtocolAPI struct {
|
||||||
|
ApiUsingConfig
|
||||||
|
transactions *services.TransactionService
|
||||||
|
transactionCategories *services.TransactionCategoryService
|
||||||
|
transactionTags *services.TransactionTagService
|
||||||
|
accounts *services.AccountService
|
||||||
|
users *services.UserService
|
||||||
|
tokens *services.TokenService
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a model context protocol api singleton instance
|
||||||
|
var (
|
||||||
|
ModelContextProtocols = &ModelContextProtocolAPI{
|
||||||
|
ApiUsingConfig: ApiUsingConfig{
|
||||||
|
container: settings.Container,
|
||||||
|
},
|
||||||
|
transactions: services.Transactions,
|
||||||
|
transactionCategories: services.TransactionCategories,
|
||||||
|
transactionTags: services.TransactionTags,
|
||||||
|
accounts: services.Accounts,
|
||||||
|
users: services.Users,
|
||||||
|
tokens: services.Tokens,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitializeHandler returns the initialize response for model context protocol
|
||||||
|
func (a *ModelContextProtocolAPI) InitializeHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||||
|
var initRequest mcp.MCPInitializeRequest
|
||||||
|
|
||||||
|
if jsonRPCRequest.Params != nil {
|
||||||
|
if err := json.Unmarshal(jsonRPCRequest.Params, &initRequest); err != nil {
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrIncompleteOrIncorrectSubmission
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[model_context_protocols.InitializeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||||
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenClaims := c.GetTokenClaims()
|
||||||
|
userTokenId, err := utils.StringToInt64(tokenClaims.UserTokenId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[model_context_protocols.InitializeHandler] parse user token id failed, because %s", err.Error())
|
||||||
|
} else {
|
||||||
|
tokenRecord := &models.TokenRecord{
|
||||||
|
Uid: tokenClaims.Uid,
|
||||||
|
UserTokenId: userTokenId,
|
||||||
|
CreatedUnixTime: tokenClaims.IssuedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenId := a.tokens.GenerateTokenId(tokenRecord)
|
||||||
|
|
||||||
|
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[model_context_protocols.InitializeHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocolVersion := mcp.MCPProtocolVersion(initRequest.ProtocolVersion)
|
||||||
|
_, exists := mcp.SupportedMCPVersion[protocolVersion]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
protocolVersion = mcp.LatestSupportedMCPVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
initResp := mcp.MCPInitializeResponse{
|
||||||
|
ProtocolVersion: string(protocolVersion),
|
||||||
|
Capabilities: &mcp.MCPCapabilities{
|
||||||
|
Tools: &mcp.MCPToolCapabilities{
|
||||||
|
ListChanged: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ServerInfo: &mcp.MCPImplementation{
|
||||||
|
Name: mcpServerName,
|
||||||
|
Title: core.ApplicationName,
|
||||||
|
Version: core.Version,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return initResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListResourcesHandler returns the list of resources for model context protocol
|
||||||
|
func (a *ModelContextProtocolAPI) ListResourcesHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[model_context_protocols.ListResourcesHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||||
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
listResourcesResp := mcp.MCPListResourcesResponse{
|
||||||
|
Resources: make([]*mcp.MCPResource, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
return listResourcesResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadResourceHandler returns the resource details for a specific resource in model context protocol
|
||||||
|
func (a *ModelContextProtocolAPI) ReadResourceHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||||
|
var readResourceReq mcp.MCPReadResourceRequest
|
||||||
|
|
||||||
|
if jsonRPCRequest.Params != nil {
|
||||||
|
if err := json.Unmarshal(jsonRPCRequest.Params, &readResourceReq); err != nil {
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrIncompleteOrIncorrectSubmission
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[model_context_protocols.ReadResourceHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||||
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errs.ErrApiNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListToolsHandler returns the list of tools for model context protocol
|
||||||
|
func (a *ModelContextProtocolAPI) ListToolsHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[model_context_protocols.ListToolsHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||||
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpVersion := a.getMCPVersion(c)
|
||||||
|
toolsInfo := mcp.Container.GetMCPTools()
|
||||||
|
finalToolsInfos := make([]*mcp.MCPTool, len(toolsInfo))
|
||||||
|
|
||||||
|
for i := 0; i < len(toolsInfo); i++ {
|
||||||
|
finalToolsInfos[i] = &mcp.MCPTool{
|
||||||
|
Name: toolsInfo[i].Name,
|
||||||
|
InputSchema: toolsInfo[i].InputSchema,
|
||||||
|
Title: toolsInfo[i].Title,
|
||||||
|
Description: toolsInfo[i].Description,
|
||||||
|
}
|
||||||
|
|
||||||
|
if mcpVersion >= string(mcp.ToolResultStructuredContentMinVersion) {
|
||||||
|
finalToolsInfos[i].OutputSchema = toolsInfo[i].OutputSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listToolsResp := mcp.MCPListToolsResponse{
|
||||||
|
Tools: finalToolsInfos,
|
||||||
|
}
|
||||||
|
|
||||||
|
return listToolsResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallToolHandler returns the result of calling a specific tool for model context protocol
|
||||||
|
func (a *ModelContextProtocolAPI) CallToolHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[model_context_protocols.CallToolHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||||
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
var callToolReq mcp.MCPCallToolRequest
|
||||||
|
|
||||||
|
if jsonRPCRequest.Params != nil {
|
||||||
|
if err := json.Unmarshal(jsonRPCRequest.Params, &callToolReq); err != nil {
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrIncompleteOrIncorrectSubmission
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := mcp.Container.HandleTool(c, &callToolReq, user, a.CurrentConfig(), a)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingHandler return the ping response for model context protocol
|
||||||
|
func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||||
|
return core.O{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTransactionService implements the MCPAvailableServices interface
|
||||||
|
func (a *ModelContextProtocolAPI) GetTransactionService() *services.TransactionService {
|
||||||
|
return a.transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTransactionCategoryService implements the MCPAvailableServices interface
|
||||||
|
func (a *ModelContextProtocolAPI) GetTransactionCategoryService() *services.TransactionCategoryService {
|
||||||
|
return a.transactionCategories
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTransactionTagService implements the MCPAvailableServices interface
|
||||||
|
func (a *ModelContextProtocolAPI) GetTransactionTagService() *services.TransactionTagService {
|
||||||
|
return a.transactionTags
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccountService implements the MCPAvailableServices interface
|
||||||
|
func (a *ModelContextProtocolAPI) GetAccountService() *services.AccountService {
|
||||||
|
return a.accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserService implements the MCPAvailableServices interface
|
||||||
|
func (a *ModelContextProtocolAPI) GetUserService() *services.UserService {
|
||||||
|
return a.users
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMCPVersion returns the MCP protocol version from the request header
|
||||||
|
func (a *ModelContextProtocolAPI) getMCPVersion(c *core.WebContext) string {
|
||||||
|
return c.GetHeader(mcp.MCPProtocolVersionHeaderName)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -35,14 +35,33 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext)
|
|||||||
builder := &strings.Builder{}
|
builder := &strings.Builder{}
|
||||||
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
|
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
|
||||||
|
|
||||||
a.appendBooleanSetting(builder, "r", config.EnableUserRegister)
|
a.appendBooleanSetting(builder, "a", config.EnableInternalAuth)
|
||||||
a.appendBooleanSetting(builder, "f", config.EnableUserForgetPassword)
|
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, "v", config.EnableUserVerifyEmail)
|
||||||
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
|
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
|
||||||
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
|
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
|
||||||
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
|
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
|
||||||
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
|
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 {
|
if config.LoginPageTips.Enabled {
|
||||||
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
|
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
|
||||||
}
|
}
|
||||||
@@ -128,7 +147,7 @@ func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key st
|
|||||||
builder.WriteString(";\n")
|
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(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||||
builder.WriteString("[")
|
builder.WriteString("[")
|
||||||
a.appendEncodedString(builder, key)
|
a.appendEncodedString(builder, key)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemsApi represents system api
|
||||||
|
type SystemsApi struct{}
|
||||||
|
|
||||||
|
// Initialize a system api singleton instance
|
||||||
|
var (
|
||||||
|
Systems = &SystemsApi{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionHandler returns the server version and commit hash
|
||||||
|
func (a *SystemsApi) VersionHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
result := make(map[string]string)
|
||||||
|
|
||||||
|
result["version"] = core.Version
|
||||||
|
result["commitHash"] = core.CommitHash
|
||||||
|
|
||||||
|
if core.BuildTime != "" {
|
||||||
|
result["buildTime"] = core.BuildTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
+156
-27
@@ -18,8 +18,9 @@ import (
|
|||||||
type TokensApi struct {
|
type TokensApi struct {
|
||||||
ApiUsingConfig
|
ApiUsingConfig
|
||||||
ApiWithUserInfo
|
ApiWithUserInfo
|
||||||
tokens *services.TokenService
|
tokens *services.TokenService
|
||||||
users *services.UserService
|
users *services.UserService
|
||||||
|
userAppCloudSettings *services.UserApplicationCloudSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a token api singleton instance
|
// Initialize a token api singleton instance
|
||||||
@@ -36,15 +37,16 @@ var (
|
|||||||
container: avatars.Container,
|
container: avatars.Container,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tokens: services.Tokens,
|
tokens: services.Tokens,
|
||||||
users: services.Users,
|
users: services.Users,
|
||||||
|
userAppCloudSettings: services.UserApplicationCloudSettings,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenListHandler returns available token list of current user
|
// TokenListHandler returns available token list of current user
|
||||||
func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
tokens, err := a.tokens.GetAllUnexpiredNormalTokensByUid(c, uid)
|
tokens, err := a.tokens.GetAllUnexpiredNormalAndMCPTokensByUid(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
@@ -67,6 +69,12 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
tokenResp.IsCurrent = true
|
tokenResp.IsCurrent = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||||
|
tokenResp.UserAgent = core.TokenUserAgentForAPI
|
||||||
|
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||||
|
tokenResp.UserAgent = core.TokenUserAgentForMCP
|
||||||
|
}
|
||||||
|
|
||||||
tokenResps[i] = tokenResp
|
tokenResps[i] = tokenResp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +83,109 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
return tokenResps, nil
|
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 {
|
||||||
|
return nil, errs.ErrMCPServerNotEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var generateMCPTokenReq models.TokenGenerateMCPRequest
|
||||||
|
err := c.ShouldBindJSON(&generateMCPTokenReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[tokens.TokenGenerateMCPHandler] parse request failed, because %s", err.Error())
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[tokens.TokenGenerateMCPHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||||
|
return false, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.users.IsPasswordEqualsUserPassword(generateMCPTokenReq.Password, user) {
|
||||||
|
return nil, errs.ErrUserPasswordWrong
|
||||||
|
}
|
||||||
|
|
||||||
|
token, claims, err := a.tokens.CreateMCPToken(c, user, 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())
|
||||||
|
return nil, errs.Or(err, errs.ErrTokenGenerating)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(c, "[tokens.TokenGenerateMCPHandler] user \"uid:%d\" has generated mcp token, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||||
|
|
||||||
|
generateMCPTokenResp := &models.TokenGenerateMCPResponse{
|
||||||
|
Token: token,
|
||||||
|
MCPUrl: a.CurrentConfig().RootUrl + "mcp",
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateMCPTokenResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TokenRevokeCurrentHandler revokes current token of current user
|
// TokenRevokeCurrentHandler revokes current token of current user
|
||||||
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
|
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 {
|
if err != nil {
|
||||||
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||||
@@ -100,11 +208,11 @@ func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
err = a.tokens.DeleteToken(c, tokenRecord)
|
err = a.tokens.DeleteToken(c, tokenRecord)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[token.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
|
log.Errorf(c, "[tokens.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof(c, "[token.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", claims.Uid, tokenId)
|
log.Infof(c, "[tokens.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", claims.Uid, tokenId)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +230,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errs.IsCustomError(err) {
|
if !errs.IsCustomError(err) {
|
||||||
log.Errorf(c, "[token.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
|
log.Errorf(c, "[tokens.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errs.Or(err, errs.ErrInvalidTokenId)
|
return nil, errs.Or(err, errs.ErrInvalidTokenId)
|
||||||
@@ -131,7 +239,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
|
|
||||||
if tokenRecord.Uid != uid {
|
if tokenRecord.Uid != uid {
|
||||||
log.Warnf(c, "[token.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
|
log.Warnf(c, "[tokens.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
|
||||||
return nil, errs.ErrInvalidTokenId
|
return nil, errs.ErrInvalidTokenId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +248,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errs.IsCustomError(err) {
|
if !errs.IsCustomError(err) {
|
||||||
log.Errorf(c, "[token.TokenRevokeHandler] failed to get user, because %s", err.Error())
|
log.Errorf(c, "[tokens.TokenRevokeHandler] failed to get user, because %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errs.ErrUserNotFound
|
return nil, errs.ErrUserNotFound
|
||||||
@@ -154,11 +262,11 @@ func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
err = a.tokens.DeleteToken(c, tokenRecord)
|
err = a.tokens.DeleteToken(c, tokenRecord)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[token.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
|
log.Errorf(c, "[tokens.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof(c, "[token.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
|
log.Infof(c, "[tokens.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +302,7 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error)
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errs.IsCustomError(err) {
|
if !errs.IsCustomError(err) {
|
||||||
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to get user, because %s", err.Error())
|
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to get user, because %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errs.ErrUserNotFound
|
return nil, errs.ErrUserNotFound
|
||||||
@@ -207,11 +315,11 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error)
|
|||||||
err = a.tokens.DeleteTokens(c, uid, tokens)
|
err = a.tokens.DeleteTokens(c, uid, tokens)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof(c, "[token.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
|
log.Infof(c, "[tokens.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +329,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
user, err := a.users.GetUserById(c, uid)
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf(c, "[token.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||||
return nil, errs.ErrUserNotFound
|
return nil, errs.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +337,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
oldTokenClaims := c.GetTokenClaims()
|
oldTokenClaims := c.GetTokenClaims()
|
||||||
|
|
||||||
if now-oldTokenClaims.IssuedAt < int64(a.CurrentConfig().TokenMinRefreshInterval) {
|
if now-oldTokenClaims.IssuedAt < int64(a.CurrentConfig().TokenMinRefreshInterval) {
|
||||||
log.Infof(c, "[token.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid)
|
log.Infof(c, "[tokens.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid)
|
||||||
|
|
||||||
userTokenId, err := utils.StringToInt64(oldTokenClaims.UserTokenId)
|
userTokenId, err := utils.StringToInt64(oldTokenClaims.UserTokenId)
|
||||||
|
|
||||||
@@ -247,13 +355,23 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
|
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf(c, "[token.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
|
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||||
|
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||||
|
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||||
|
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||||
|
}
|
||||||
|
|
||||||
refreshResp := &models.TokenRefreshResponse{
|
refreshResp := &models.TokenRefreshResponse{
|
||||||
User: a.GetUserBasicInfo(user),
|
User: a.GetUserBasicInfo(user),
|
||||||
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
|
ApplicationCloudSettings: applicationCloudSettingSlice,
|
||||||
|
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
|
||||||
}
|
}
|
||||||
|
|
||||||
return refreshResp, nil
|
return refreshResp, nil
|
||||||
@@ -262,7 +380,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
token, claims, err := a.tokens.CreateToken(c, user)
|
token, claims, err := a.tokens.CreateToken(c, user)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[token.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
log.Errorf(c, "[tokens.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrTokenGenerating)
|
return nil, errs.Or(err, errs.ErrTokenGenerating)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,14 +393,25 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
|
|
||||||
c.SetTextualToken(token)
|
c.SetTextualToken(token)
|
||||||
c.SetTokenClaims(claims)
|
c.SetTokenClaims(claims)
|
||||||
|
c.SetTokenContext("")
|
||||||
|
|
||||||
log.Infof(c, "[token.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||||
|
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[tokens.TokenRefreshHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||||
|
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||||
|
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(c, "[tokens.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||||
|
|
||||||
refreshResp := &models.TokenRefreshResponse{
|
refreshResp := &models.TokenRefreshResponse{
|
||||||
NewToken: token,
|
NewToken: token,
|
||||||
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
|
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
|
||||||
User: a.GetUserBasicInfo(user),
|
User: a.GetUserBasicInfo(user),
|
||||||
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
|
ApplicationCloudSettings: applicationCloudSettingSlice,
|
||||||
|
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
|
||||||
}
|
}
|
||||||
|
|
||||||
return refreshResp, nil
|
return refreshResp, nil
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.WebContext) (an
|
|||||||
|
|
||||||
log.Infof(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
|
log.Infof(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
|
||||||
|
|
||||||
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId))
|
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId))
|
||||||
categoryResp := category.ToTransactionCategoryInfoResponse()
|
categoryResp := category.ToTransactionCategoryInfoResponse()
|
||||||
|
|
||||||
return categoryResp, nil
|
return categoryResp, nil
|
||||||
@@ -214,6 +214,7 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (an
|
|||||||
Uid: uid,
|
Uid: uid,
|
||||||
ParentCategoryId: categoryModifyReq.ParentId,
|
ParentCategoryId: categoryModifyReq.ParentId,
|
||||||
Name: categoryModifyReq.Name,
|
Name: categoryModifyReq.Name,
|
||||||
|
DisplayOrder: category.DisplayOrder,
|
||||||
Icon: categoryModifyReq.Icon,
|
Icon: categoryModifyReq.Icon,
|
||||||
Color: categoryModifyReq.Color,
|
Color: categoryModifyReq.Color,
|
||||||
Comment: categoryModifyReq.Comment,
|
Comment: categoryModifyReq.Comment,
|
||||||
@@ -229,11 +230,11 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (an
|
|||||||
return nil, errs.ErrNothingWillBeUpdated
|
return nil, errs.ErrNothingWillBeUpdated
|
||||||
}
|
}
|
||||||
|
|
||||||
if category.ParentCategoryId == 0 && newCategory.ParentCategoryId != 0 {
|
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId && newCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
|
||||||
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionCategoryToSecondary)
|
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionCategoryToSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if category.ParentCategoryId != 0 && newCategory.ParentCategoryId == 0 {
|
if category.ParentCategoryId != models.LevelOneTransactionCategoryParentId && newCategory.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
|
||||||
return nil, errs.Or(err, errs.ErrNotAllowChangeSecondaryTransactionCategoryToPrimary)
|
return nil, errs.Or(err, errs.ErrNotAllowChangeSecondaryTransactionCategoryToPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,9 +257,18 @@ func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (an
|
|||||||
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionType)
|
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if toPrimaryCategory.ParentCategoryId != 0 {
|
if toPrimaryCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
|
||||||
return nil, errs.Or(err, errs.ErrNotAllowUseSecondaryTransactionAsPrimaryCategory)
|
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)
|
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)
|
log.Infof(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
|
||||||
|
|
||||||
newCategory.Type = category.Type
|
newCategory.Type = category.Type
|
||||||
newCategory.DisplayOrder = category.DisplayOrder
|
|
||||||
categoryResp := newCategory.ToTransactionCategoryInfoResponse()
|
categoryResp := newCategory.ToTransactionCategoryInfoResponse()
|
||||||
|
|
||||||
return categoryResp, nil
|
return categoryResp, nil
|
||||||
@@ -433,7 +442,7 @@ func (a *TransactionCategoriesApi) getTransactionCategoryListByTypeResponse(cate
|
|||||||
for i := 0; i < len(categoryResps); i++ {
|
for i := 0; i < len(categoryResps); i++ {
|
||||||
categoryResp := categoryResps[i]
|
categoryResp := categoryResps[i]
|
||||||
|
|
||||||
if categoryResp.ParentId <= models.LevelOneTransactionParentId {
|
if categoryResp.ParentId <= models.LevelOneTransactionCategoryParentId {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +458,7 @@ func (a *TransactionCategoriesApi) getTransactionCategoryListByTypeResponse(cate
|
|||||||
finalCategoryResps := make(models.TransactionCategoryInfoResponseSlice, 0)
|
finalCategoryResps := make(models.TransactionCategoryInfoResponseSlice, 0)
|
||||||
|
|
||||||
for i := 0; i < len(categoryResps); i++ {
|
for i := 0; i < len(categoryResps); i++ {
|
||||||
if parentId <= 0 && categoryResps[i].ParentId == models.LevelOneTransactionParentId {
|
if parentId <= 0 && categoryResps[i].ParentId == models.LevelOneTransactionCategoryParentId {
|
||||||
sort.Sort(categoryResps[i].SubCategories)
|
sort.Sort(categoryResps[i].SubCategories)
|
||||||
finalCategoryResps = append(finalCategoryResps, categoryResps[i])
|
finalCategoryResps = append(finalCategoryResps, categoryResps[i])
|
||||||
} else if parentId > 0 && categoryResps[i].ParentId == parentId {
|
} else if parentId > 0 && categoryResps[i].ParentId == parentId {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func (a *TransactionPicturesApi) TransactionPictureUploadHandler(c *core.WebCont
|
|||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId, utils.Int64ToString(pictureInfo.PictureId))
|
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId, utils.Int64ToString(pictureInfo.PictureId))
|
||||||
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
|
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
|
||||||
|
|
||||||
return pictureInfoResp, nil
|
return pictureInfoResp, nil
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-9
@@ -12,13 +12,15 @@ import (
|
|||||||
|
|
||||||
// TransactionTagsApi represents transaction tag api
|
// TransactionTagsApi represents transaction tag api
|
||||||
type TransactionTagsApi struct {
|
type TransactionTagsApi struct {
|
||||||
tags *services.TransactionTagService
|
tags *services.TransactionTagService
|
||||||
|
tagGroups *services.TransactionTagGroupService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a transaction tag api singleton instance
|
// Initialize a transaction tag api singleton instance
|
||||||
var (
|
var (
|
||||||
TransactionTags = &TransactionTagsApi{
|
TransactionTags = &TransactionTagsApi{
|
||||||
tags: services.TransactionTags,
|
tags: services.TransactionTags,
|
||||||
|
tagGroups: services.TransactionTagGroups,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,7 +80,21 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
|
|
||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
|
|
||||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
|
if tagCreateReq.GroupId > 0 {
|
||||||
|
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateReq.GroupId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateReq.GroupId, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagGroup == nil {
|
||||||
|
log.Warnf(c, "[transaction_tags.TagCreateHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateReq.GroupId, uid)
|
||||||
|
return nil, errs.ErrTransactionTagGroupNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
@@ -101,6 +117,68 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
return tagResp, nil
|
return tagResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TagCreateBatchHandler saves some new transaction tags by request parameters for current user
|
||||||
|
func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
var tagCreateBatchReq models.TransactionTagCreateBatchRequest
|
||||||
|
err := c.ShouldBindJSON(&tagCreateBatchReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] parse request failed, because %s", err.Error())
|
||||||
|
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if tagCreateBatchReq.GroupId > 0 {
|
||||||
|
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateBatchReq.GroupId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateBatchReq.GroupId, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagGroup == nil {
|
||||||
|
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateBatchReq.GroupId, uid)
|
||||||
|
return nil, errs.ErrTransactionTagGroupNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := a.createNewTagModels(uid, &tagCreateBatchReq, maxOrderId+1)
|
||||||
|
|
||||||
|
err = a.tags.CreateTags(c, uid, tags, tagCreateBatchReq.SkipExists)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to create tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(c, "[transaction_tags.TagCreateBatchHandler] user \"uid:%d\" has created tags successfully", uid)
|
||||||
|
|
||||||
|
tagResps := make(models.TransactionTagInfoResponseSlice, len(tags))
|
||||||
|
|
||||||
|
for i := 0; i < len(tags); i++ {
|
||||||
|
tagResps[i] = tags[i].ToTransactionTagInfoResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(tagResps)
|
||||||
|
|
||||||
|
return tagResps, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TagModifyHandler saves an existed transaction tag by request parameters for current user
|
// TagModifyHandler saves an existed transaction tag by request parameters for current user
|
||||||
func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
var tagModifyReq models.TransactionTagModifyRequest
|
var tagModifyReq models.TransactionTagModifyRequest
|
||||||
@@ -119,17 +197,46 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
newTag := &models.TransactionTag{
|
if tagModifyReq.GroupId != tag.TagGroupId && tagModifyReq.GroupId > 0 {
|
||||||
TagId: tag.TagId,
|
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagModifyReq.GroupId)
|
||||||
Uid: uid,
|
|
||||||
Name: tagModifyReq.Name,
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.GroupId, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagGroup == nil {
|
||||||
|
log.Warnf(c, "[transaction_tags.TagModifyHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagModifyReq.GroupId, uid)
|
||||||
|
return nil, errs.ErrTransactionTagGroupNotFound
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if newTag.Name == tag.Name {
|
newTag := &models.TransactionTag{
|
||||||
|
TagId: tag.TagId,
|
||||||
|
Uid: uid,
|
||||||
|
Name: tagModifyReq.Name,
|
||||||
|
TagGroupId: tagModifyReq.GroupId,
|
||||||
|
DisplayOrder: tag.DisplayOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
tagNameChanged := newTag.Name != tag.Name
|
||||||
|
|
||||||
|
if !tagNameChanged && newTag.TagGroupId == tag.TagGroupId {
|
||||||
return nil, errs.ErrNothingWillBeUpdated
|
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 {
|
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())
|
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
|
||||||
@@ -139,6 +246,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)
|
log.Infof(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
|
||||||
|
|
||||||
tag.Name = newTag.Name
|
tag.Name = newTag.Name
|
||||||
|
tag.TagGroupId = newTag.TagGroupId
|
||||||
|
tag.DisplayOrder = newTag.DisplayOrder
|
||||||
tagResp := tag.ToTransactionTagInfoResponse()
|
tagResp := tag.ToTransactionTagInfoResponse()
|
||||||
|
|
||||||
return tagResp, nil
|
return tagResp, nil
|
||||||
@@ -227,6 +336,20 @@ func (a *TransactionTagsApi) createNewTagModel(uid int64, tagCreateReq *models.T
|
|||||||
return &models.TransactionTag{
|
return &models.TransactionTag{
|
||||||
Uid: uid,
|
Uid: uid,
|
||||||
Name: tagCreateReq.Name,
|
Name: tagCreateReq.Name,
|
||||||
|
TagGroupId: tagCreateReq.GroupId,
|
||||||
DisplayOrder: order,
|
DisplayOrder: order,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *TransactionTagsApi) createNewTagModels(uid int64, tagCreateBatchReq *models.TransactionTagCreateBatchRequest, order int32) []*models.TransactionTag {
|
||||||
|
tags := make([]*models.TransactionTag, len(tagCreateBatchReq.Tags))
|
||||||
|
|
||||||
|
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
|
||||||
|
tagCreateReq := tagCreateBatchReq.Tags[i]
|
||||||
|
tag := a.createNewTagModel(uid, tagCreateReq, order+int32(i))
|
||||||
|
tag.TagGroupId = tagCreateBatchReq.GroupId
|
||||||
|
tags[i] = tag
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any
|
|||||||
|
|
||||||
log.Infof(c, "[transaction_templates.TemplateCreateHandler] user \"uid:%d\" has created a new template \"id:%d\" successfully", uid, template.TemplateId)
|
log.Infof(c, "[transaction_templates.TemplateCreateHandler] user \"uid:%d\" has created a new template \"id:%d\" successfully", uid, template.TemplateId)
|
||||||
|
|
||||||
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId, utils.Int64ToString(template.TemplateId))
|
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId, utils.Int64ToString(template.TemplateId))
|
||||||
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
|
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
|
||||||
|
|
||||||
return templateResp, nil
|
return templateResp, nil
|
||||||
|
|||||||
+696
-257
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.WebCo
|
|||||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user)
|
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user, c.GetClientLocale())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor secret, because %s", err.Error())
|
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor secret, because %s", err.Error())
|
||||||
@@ -205,6 +205,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.WebCo
|
|||||||
|
|
||||||
c.SetTextualToken(token)
|
c.SetTextualToken(token)
|
||||||
c.SetTokenClaims(claims)
|
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)
|
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserApplicationCloudSettingsApi represents user application cloud settings api
|
||||||
|
type UserApplicationCloudSettingsApi struct {
|
||||||
|
userAppCloudSettings *services.UserApplicationCloudSettingsService
|
||||||
|
users *services.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a user application cloud settings api singleton instance
|
||||||
|
var (
|
||||||
|
UserApplicationCloudSettings = &UserApplicationCloudSettingsApi{
|
||||||
|
userAppCloudSettings: services.UserApplicationCloudSettings,
|
||||||
|
users: services.Users,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplicationSettingsGetHandler returns application cloud settings of current user
|
||||||
|
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsGetHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
|
||||||
|
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsGetHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if userApplicationCloudSettings == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationCloudSettingSlice := userApplicationCloudSettings.Settings
|
||||||
|
|
||||||
|
if len(applicationCloudSettingSlice) < 1 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicationCloudSettingSlice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationSettingsUpdateHandler updates user application cloud settings by request parameters for current user
|
||||||
|
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsUpdateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
var userAppCloudSettingUpdateReq models.UserApplicationCloudSettingsUpdateRequest
|
||||||
|
err := c.ShouldBindJSON(&userAppCloudSettingUpdateReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] parse request failed, because %s", err.Error())
|
||||||
|
return false, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if !errs.IsCustomError(err) {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS) {
|
||||||
|
return false, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
var userApplicationCloudSettings *models.UserApplicationCloudSetting
|
||||||
|
|
||||||
|
// Retry up to 3 times
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
userApplicationCloudSettings, err = a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\" (try count %d), because %s", uid, i+1, err.Error())
|
||||||
|
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||||
|
lastUpdateTime := int64(0)
|
||||||
|
|
||||||
|
if userApplicationCloudSettings != nil {
|
||||||
|
for _, setting := range userApplicationCloudSettings.Settings {
|
||||||
|
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdateTime = userApplicationCloudSettings.UpdatedUnixTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the full update settings are the same as the existing settings
|
||||||
|
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||||
|
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
|
||||||
|
needUpdate := false
|
||||||
|
|
||||||
|
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||||
|
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||||
|
|
||||||
|
if !exists || oldSetting.SettingValue != setting.SettingValue {
|
||||||
|
needUpdate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !needUpdate {
|
||||||
|
return false, errs.ErrNothingWillBeUpdated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
|
||||||
|
needUpdate := true
|
||||||
|
|
||||||
|
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||||
|
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
needUpdate = false
|
||||||
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync (try count %d)", setting.SettingKey, i+1)
|
||||||
|
} else if cloudSetting.SettingValue == setting.SettingValue {
|
||||||
|
needUpdate = false
|
||||||
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update (try count %d)", setting.SettingKey, setting.SettingValue, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !needUpdate {
|
||||||
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\" (try count %d)", uid, i+1)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||||
|
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
|
||||||
|
|
||||||
|
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||||
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings (try count %d)", uid, i+1)
|
||||||
|
} else {
|
||||||
|
if len(oldApplicationCloudSettingsMap) > 0 {
|
||||||
|
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings (try count %d)", uid, i+1)
|
||||||
|
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||||
|
newApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||||
|
}
|
||||||
|
|
||||||
|
for settingKey, setting := range newApplicationCloudSettingsMap {
|
||||||
|
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync (try count %d)", settingKey, i+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
|
||||||
|
// Do Nothing
|
||||||
|
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
|
||||||
|
_, err := utils.StringToFloat64(setting.SettingValue)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
|
||||||
|
if setting.SettingValue != "true" && setting.SettingValue != "false" {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
|
||||||
|
var settingValueMap map[string]bool
|
||||||
|
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\" (try count %d), because %s", settingKey, setting.SettingValue, i+1, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\" (try count %d)", settingKey, settingType, i+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice, userAppCloudSettingUpdateReq.FullUpdate, lastUpdateTime)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond) // Wait for 100 milliseconds before retrying
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationSettingsDisableHandler disabled user application cloud settings by request parameters for current user
|
||||||
|
func (a *UserApplicationCloudSettingsApi) ApplicationSettingsDisableHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
user, err := a.users.GetUserById(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if !errs.IsCustomError(err) {
|
||||||
|
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS) {
|
||||||
|
return false, errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.userAppCloudSettings.ClearUserApplicationCloudSettings(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsDisableHandler] failed to clear user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+65
-7
@@ -78,11 +78,12 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
Language: userRegisterReq.Language,
|
Language: userRegisterReq.Language,
|
||||||
DefaultCurrency: userRegisterReq.DefaultCurrency,
|
DefaultCurrency: userRegisterReq.DefaultCurrency,
|
||||||
FirstDayOfWeek: userRegisterReq.FirstDayOfWeek,
|
FirstDayOfWeek: userRegisterReq.FirstDayOfWeek,
|
||||||
|
FiscalYearStart: core.FISCAL_YEAR_START_DEFAULT,
|
||||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||||
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.users.CreateUser(c, user)
|
err = a.users.CreateUser(c, user, false)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
|
log.Errorf(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||||
@@ -141,8 +142,9 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
authResp.Token = token
|
authResp.Token = token
|
||||||
c.SetTextualToken(token)
|
c.SetTextualToken(token)
|
||||||
c.SetTokenClaims(claims)
|
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
|
return authResp, nil
|
||||||
}
|
}
|
||||||
@@ -204,6 +206,7 @@ func (a *UsersApi) UserEmailVerifyHandler(c *core.WebContext) (any, *errs.Error)
|
|||||||
|
|
||||||
c.SetTextualToken(token)
|
c.SetTextualToken(token)
|
||||||
c.SetTokenClaims(claims)
|
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)
|
log.Infof(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||||
}
|
}
|
||||||
@@ -274,7 +277,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||||
}
|
}
|
||||||
|
|
||||||
if !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
|
if user.Password != "" && !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
|
||||||
return nil, errs.ErrUserPasswordWrong
|
return nil, errs.ErrUserPasswordWrong
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +352,33 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
|
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.FiscalYearStart != nil && *userUpdateReq.FiscalYearStart != user.FiscalYearStart {
|
||||||
|
user.FiscalYearStart = *userUpdateReq.FiscalYearStart
|
||||||
|
userNew.FiscalYearStart = *userUpdateReq.FiscalYearStart
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.CalendarDisplayType != nil && *userUpdateReq.CalendarDisplayType != user.CalendarDisplayType {
|
||||||
|
user.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
|
||||||
|
userNew.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.CalendarDisplayType = core.CALENDAR_DISPLAY_TYPE_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.DateDisplayType != nil && *userUpdateReq.DateDisplayType != user.DateDisplayType {
|
||||||
|
user.DateDisplayType = *userUpdateReq.DateDisplayType
|
||||||
|
userNew.DateDisplayType = *userUpdateReq.DateDisplayType
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.DateDisplayType = core.DATE_DISPLAY_TYPE_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
|
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
|
||||||
user.LongDateFormat = *userUpdateReq.LongDateFormat
|
user.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||||
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
|
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||||
@@ -385,6 +415,33 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
|
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.FiscalYearFormat != nil && *userUpdateReq.FiscalYearFormat != user.FiscalYearFormat {
|
||||||
|
user.FiscalYearFormat = *userUpdateReq.FiscalYearFormat
|
||||||
|
userNew.FiscalYearFormat = *userUpdateReq.FiscalYearFormat
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
||||||
|
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||||
|
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
if userUpdateReq.NumeralSystem != nil && *userUpdateReq.NumeralSystem != user.NumeralSystem {
|
||||||
|
user.NumeralSystem = *userUpdateReq.NumeralSystem
|
||||||
|
userNew.NumeralSystem = *userUpdateReq.NumeralSystem
|
||||||
|
modifyProfileBasicInfo = true
|
||||||
|
anythingUpdate = true
|
||||||
|
} else {
|
||||||
|
userNew.NumeralSystem = core.NUMERAL_SYSTEM_INVALID
|
||||||
|
}
|
||||||
|
|
||||||
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
|
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
|
||||||
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||||
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||||
@@ -412,13 +469,13 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
|
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
|
||||||
}
|
}
|
||||||
|
|
||||||
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
|
||||||
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
||||||
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
||||||
modifyProfileBasicInfo = true
|
modifyProfileBasicInfo = true
|
||||||
anythingUpdate = true
|
anythingUpdate = true
|
||||||
} else {
|
} else {
|
||||||
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
userNew.CoordinateDisplayType = core.COORDINATE_DISPLAY_TYPE_INVALID
|
||||||
}
|
}
|
||||||
|
|
||||||
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
|
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
|
||||||
@@ -533,6 +590,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
|||||||
resp.NewToken = token
|
resp.NewToken = token
|
||||||
c.SetTextualToken(token)
|
c.SetTextualToken(token)
|
||||||
c.SetTokenClaims(claims)
|
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)
|
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, core.GetOutgoingUserAgent(), 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
|
||||||
|
}
|
||||||
@@ -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,7 +9,7 @@ import (
|
|||||||
|
|
||||||
// AvatarProviderContainer contains the current user avatar provider
|
// AvatarProviderContainer contains the current user avatar provider
|
||||||
type AvatarProviderContainer struct {
|
type AvatarProviderContainer struct {
|
||||||
Current AvatarProvider
|
current AvatarProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a user avatar provider container singleton instance
|
// Initialize a user avatar provider container singleton instance
|
||||||
@@ -20,13 +20,13 @@ var (
|
|||||||
// InitializeAvatarProvider initializes the current user avatar provider according to the config
|
// InitializeAvatarProvider initializes the current user avatar provider according to the config
|
||||||
func InitializeAvatarProvider(config *settings.Config) error {
|
func InitializeAvatarProvider(config *settings.Config) error {
|
||||||
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
||||||
Container.Current = NewInternalStorageAvatarProvider(config)
|
Container.current = NewInternalStorageAvatarProvider(config)
|
||||||
return nil
|
return nil
|
||||||
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
|
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
|
||||||
Container.Current = NewGravatarAvatarProvider()
|
Container.current = NewGravatarAvatarProvider()
|
||||||
return nil
|
return nil
|
||||||
} else if config.AvatarProvider == "" {
|
} else if config.AvatarProvider == "" {
|
||||||
Container.Current = NewNullAvatarProvider()
|
Container.current = NewNullAvatarProvider()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,5 +35,9 @@ func InitializeAvatarProvider(config *settings.Config) error {
|
|||||||
|
|
||||||
// GetAvatarUrl returns the avatar url by the current user avatar provider
|
// GetAvatarUrl returns the avatar url by the current user avatar provider
|
||||||
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
|
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
@@ -9,5 +9,5 @@ type CliUsingConfig struct {
|
|||||||
|
|
||||||
// CurrentConfig returns the current config
|
// CurrentConfig returns the current config
|
||||||
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
|
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
|
||||||
return l.container.Current
|
return l.container.GetCurrentConfig()
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-12
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters"
|
"github.com/mayswind/ezbookkeeping/pkg/converters"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"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,
|
FeatureRestriction: l.CurrentConfig().DefaultFeatureRestrictions,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := l.users.CreateUser(c, user)
|
err := l.users.CreateUser(c, user, false)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.CliErrorf(c, "[user_data.AddNewUser] failed to create user \"%s\", because %s", user.Username, err.Error())
|
log.CliErrorf(c, "[user_data.AddNewUser] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||||
@@ -149,7 +150,7 @@ func (l *UserDataCli) ModifyUserPassword(c *core.CliContext, username string, pa
|
|||||||
Password: password,
|
Password: password,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = l.users.UpdateUser(c, userNew, false)
|
err = l.users.UpdateUserPassword(c, userNew)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.CliErrorf(c, "[user_data.ModifyUserPassword] failed to update user \"%s\" password, because %s", user.Username, err.Error())
|
log.CliErrorf(c, "[user_data.ModifyUserPassword] failed to update user \"%s\" password, because %s", user.Username, err.Error())
|
||||||
@@ -394,7 +395,7 @@ func (l *UserDataCli) ListUserTokens(c *core.CliContext, username string) ([]*mo
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := l.tokens.GetAllUnexpiredNormalTokensByUid(c, uid)
|
tokens, err := l.tokens.GetAllUnexpiredNormalAndMCPTokensByUid(c, uid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.CliErrorf(c, "[user_data.ListUserTokens] failed to get tokens of user \"%s\", because %s", username, err.Error())
|
log.CliErrorf(c, "[user_data.ListUserTokens] failed to get tokens of user \"%s\", because %s", username, err.Error())
|
||||||
@@ -405,7 +406,7 @@ func (l *UserDataCli) ListUserTokens(c *core.CliContext, username string) ([]*mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewUserToken returns a new token for the specified user
|
// CreateNewUserToken returns a new token for the specified user
|
||||||
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string) (*models.TokenRecord, string, error) {
|
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, tokenType string, expiresInSeconds int64) (*models.TokenRecord, string, error) {
|
||||||
if username == "" {
|
if username == "" {
|
||||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] user name is empty")
|
log.CliErrorf(c, "[user_data.CreateNewUserToken] user name is empty")
|
||||||
return nil, "", errs.ErrUsernameIsEmpty
|
return nil, "", errs.ErrUsernameIsEmpty
|
||||||
@@ -418,7 +419,32 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string) (*
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
token, tokenRecord, err := l.tokens.CreateTokenViaCli(c, user)
|
var token string
|
||||||
|
var tokenRecord *models.TokenRecord
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS) {
|
||||||
|
return nil, "", errs.ErrNotPermittedToPerformThisAction
|
||||||
|
}
|
||||||
|
|
||||||
|
token, tokenRecord, err = l.tokens.CreateMCPTokenViaCli(c, user, expiresInSeconds)
|
||||||
|
} else {
|
||||||
|
return nil, "", errs.ErrParameterInvalid
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] failed to create token for user \"%s\", because %s", username, err.Error())
|
log.CliErrorf(c, "[user_data.CreateNewUserToken] failed to create token for user \"%s\", because %s", username, err.Error())
|
||||||
@@ -428,6 +454,39 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string) (*
|
|||||||
return tokenRecord, token, nil
|
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
|
// ClearUserTokens clears all tokens of the specified user
|
||||||
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
|
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
|
||||||
if username == "" {
|
if username == "" {
|
||||||
@@ -760,7 +819,7 @@ func (l *UserDataCli) ImportTransaction(c *core.CliContext, username string, fil
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
log.CliErrorf(c, "[user_data.ImportTransaction] failed to parse imported data for \"%s\", because %s", username, err.Error())
|
log.CliErrorf(c, "[user_data.ImportTransaction] failed to parse imported data for \"%s\", because %s", username, err.Error())
|
||||||
@@ -810,7 +869,7 @@ func (l *UserDataCli) ImportTransaction(c *core.CliContext, username string, fil
|
|||||||
return errs.ErrOperationFailed
|
return errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap)
|
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions, newTransactionTagIdsMap, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.CliErrorf(c, "[user_data.ImportTransaction] failed to create transaction, because %s", err.Error())
|
log.CliErrorf(c, "[user_data.ImportTransaction] failed to create transaction, because %s", err.Error())
|
||||||
@@ -876,7 +935,7 @@ func (l *UserDataCli) getUserEssentialData(c *core.CliContext, uid int64, userna
|
|||||||
return accountMap, categoryMap, tagMap, tagIndexes, tagIndexesMap, nil
|
return accountMap, categoryMap, tagMap, tagIndexes, tagIndexesMap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int64, username string) (accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag, err error) {
|
func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int64, username string) (accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag, err error) {
|
||||||
if uid <= 0 {
|
if uid <= 0 {
|
||||||
log.CliErrorf(c, "[user_data.getUserEssentialDataForImport] user uid \"%d\" is invalid", uid)
|
log.CliErrorf(c, "[user_data.getUserEssentialDataForImport] user uid \"%d\" is invalid", uid)
|
||||||
return nil, nil, nil, nil, nil, errs.ErrUserIdInvalid
|
return nil, nil, nil, nil, nil, errs.ErrUserIdInvalid
|
||||||
@@ -889,7 +948,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
|
|||||||
return nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
accountMap = l.accounts.GetAccountNameMapByList(accounts)
|
accountMap = l.accounts.GetVisibleAccountNameMapByList(accounts)
|
||||||
|
|
||||||
categories, err := l.categories.GetAllCategoriesByUid(c, uid, 0, -1)
|
categories, err := l.categories.GetAllCategoriesByUid(c, uid, 0, -1)
|
||||||
|
|
||||||
@@ -898,7 +957,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
|
|||||||
return nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
expenseCategoryMap, incomeCategoryMap, transferCategoryMap = l.categories.GetCategoryNameMapByList(categories)
|
expenseCategoryMap, incomeCategoryMap, transferCategoryMap = l.categories.GetVisibleSubCategoryNameMapByList(categories)
|
||||||
|
|
||||||
tags, err := l.tags.GetAllTagsByUid(c, uid)
|
tags, err := l.tags.GetAllTagsByUid(c, uid)
|
||||||
|
|
||||||
@@ -907,7 +966,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
|
|||||||
return nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tagMap = l.tags.GetTagNameMapByList(tags)
|
tagMap = l.tags.GetVisibleTagNameMapByList(tags)
|
||||||
|
|
||||||
return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil
|
return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil
|
||||||
}
|
}
|
||||||
@@ -959,7 +1018,7 @@ func (l *UserDataCli) checkTransactionCategory(c *core.CliContext, transaction *
|
|||||||
return errs.ErrTransactionCategoryNotFound
|
return errs.ErrTransactionCategoryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if category.ParentCategoryId == models.LevelOneTransactionParentId {
|
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
|
||||||
log.CliErrorf(c, "[user_data.checkTransactionCategory] the transaction category \"id:%d\" of transaction \"id:%d\" is not a sub category", transaction.CategoryId, transaction.TransactionId)
|
log.CliErrorf(c, "[user_data.checkTransactionCategory] the transaction category \"id:%d\" of transaction \"id:%d\" is not a sub category", transaction.CategoryId, transaction.TransactionId)
|
||||||
return errs.ErrOperationFailed
|
return errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ var (
|
|||||||
AlipayAppTransactionDataCsvFileImporter = &alipayAppTransactionDataCsvFileImporter{
|
AlipayAppTransactionDataCsvFileImporter = &alipayAppTransactionDataCsvFileImporter{
|
||||||
alipayTransactionDataCsvFileImporter{
|
alipayTransactionDataCsvFileImporter{
|
||||||
fileHeaderLine: "------------------------------------------------------------------------------------",
|
fileHeaderLine: "------------------------------------------------------------------------------------",
|
||||||
dataHeaderStartContent: "支付宝(中国)网络技术有限公司 电子客户回单",
|
dataHeaderStartContent: []string{"支付宝(中国)网络技术有限公司 电子客户回单", "支付宝支付科技有限公司 电子客户回单"},
|
||||||
originalColumnNames: alipayTransactionColumnNames{
|
originalColumnNames: alipayTransactionColumnNames{
|
||||||
timeColumnName: "交易时间",
|
timeColumnName: "交易时间",
|
||||||
categoryColumnName: "交易分类",
|
categoryColumnName: "交易分类",
|
||||||
|
|||||||
@@ -2,20 +2,18 @@ package alipay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
"time"
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
"golang.org/x/text/transform"
|
"golang.org/x/text/transform"
|
||||||
|
|
||||||
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||||
@@ -50,23 +48,29 @@ type alipayTransactionColumnNames struct {
|
|||||||
// alipayTransactionDataCsvFileImporter defines the structure of alipay csv importer for transaction data
|
// alipayTransactionDataCsvFileImporter defines the structure of alipay csv importer for transaction data
|
||||||
type alipayTransactionDataCsvFileImporter struct {
|
type alipayTransactionDataCsvFileImporter struct {
|
||||||
fileHeaderLine string
|
fileHeaderLine string
|
||||||
dataHeaderStartContent string
|
dataHeaderStartContent []string
|
||||||
dataBottomEndLineRune rune
|
dataBottomEndLineRune rune
|
||||||
originalColumnNames alipayTransactionColumnNames
|
originalColumnNames alipayTransactionColumnNames
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImportedData returns the imported data by parsing the alipay transaction csv data
|
// ParseImportedData returns the imported data by parsing the alipay transaction csv data
|
||||||
func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, 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
|
enc := simplifiedchinese.GB18030
|
||||||
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
|
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
|
||||||
|
|
||||||
dataTable, err := c.createNewAlipayImportedDataTable(ctx, reader, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
|
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
commonDataTable := datatable.CreateNewImportedCommonDataTable(dataTable)
|
dataTable, err := createNewAlipayTransactionBasicDataTable(ctx, csvDataTable, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
|
||||||
|
|
||||||
if !commonDataTable.HasColumn(c.originalColumnNames.timeColumnName) ||
|
if !commonDataTable.HasColumn(c.originalColumnNames.timeColumnName) ||
|
||||||
!commonDataTable.HasColumn(c.originalColumnNames.amountColumnName) ||
|
!commonDataTable.HasColumn(c.originalColumnNames.amountColumnName) ||
|
||||||
@@ -76,86 +80,9 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
|
|||||||
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionRowParser := createAlipayTransactionDataRowParser(c.originalColumnNames)
|
transactionRowParser := createAlipayTransactionDataRowParser(c.originalColumnNames, dataTable.HeaderColumnNames())
|
||||||
transactionDataTable := datatable.CreateNewCommonTransactionDataTable(commonDataTable, alipayTransactionSupportedColumns, transactionRowParser)
|
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, alipayTransactionSupportedColumns, transactionRowParser)
|
||||||
dataTableImporter := datatable.CreateNewSimpleImporter(alipayTransactionTypeNameMapping)
|
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(alipayTransactionTypeNameMapping)
|
||||||
|
|
||||||
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)
|
||||||
}
|
|
||||||
|
|
||||||
func (c *alipayTransactionDataCsvFileImporter) createNewAlipayImportedDataTable(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.ImportedDataTable, error) {
|
|
||||||
csvReader := csv.NewReader(reader)
|
|
||||||
csvReader.FieldsPerRecord = -1
|
|
||||||
|
|
||||||
allOriginalLines := make([][]string, 0)
|
|
||||||
hasFileHeader := false
|
|
||||||
foundContentBeforeDataHeaderLine := false
|
|
||||||
|
|
||||||
for {
|
|
||||||
items, err := csvReader.Read()
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse alipay csv data, because %s", err.Error())
|
|
||||||
return nil, errs.ErrInvalidCSVFile
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasFileHeader {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if strings.Index(items[0], fileHeaderLine) == 0 {
|
|
||||||
hasFileHeader = true
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
log.Warnf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundContentBeforeDataHeaderLine {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if strings.Index(items[0], dataHeaderStartContent) >= 0 {
|
|
||||||
foundContentBeforeDataHeaderLine = true
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundContentBeforeDataHeaderLine {
|
|
||||||
if len(items) <= 0 {
|
|
||||||
continue
|
|
||||||
} else if len(items) == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(items[0], dataBottomEndLineRune) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(items); i++ {
|
|
||||||
items[i] = strings.Trim(items[i], " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
|
||||||
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse row \"index:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", len(allOriginalLines), len(items), len(allOriginalLines[0]))
|
|
||||||
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
|
||||||
}
|
|
||||||
|
|
||||||
allOriginalLines = append(allOriginalLines, items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
|
||||||
return nil, errs.ErrInvalidFileHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allOriginalLines) < 2 {
|
|
||||||
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse import data, because data table row count is less 1")
|
|
||||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
|
||||||
}
|
|
||||||
|
|
||||||
dataTable := csvdatatable.CreateNewCustomCsvImportedDataTable(allOriginalLines)
|
|
||||||
|
|
||||||
return dataTable, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -35,7 +36,7 @@ func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 4, len(allNewTransactions))
|
assert.Equal(t, 4, len(allNewTransactions))
|
||||||
@@ -94,7 +95,7 @@ func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -102,6 +103,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
|||||||
DefaultCurrency: "CNY",
|
DefaultCurrency: "CNY",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refund
|
||||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||||
"账号:[xxx@xxx.xxx]\n" +
|
"账号:[xxx@xxx.xxx]\n" +
|
||||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||||
@@ -111,7 +113,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
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].OriginalSourceAccountName)
|
||||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||||
|
|
||||||
|
// tax refund
|
||||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||||
"账号:[xxx@xxx.xxx]\n" +
|
"账号:[xxx@xxx.xxx]\n" +
|
||||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||||
@@ -130,7 +133,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||||
@@ -141,8 +144,48 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
|||||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
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) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -159,7 +202,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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)
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
|
|
||||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||||
@@ -171,12 +214,12 @@ func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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)
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -193,12 +236,12 @@ func TestAlipayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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)
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -216,7 +259,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -232,7 +275,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -248,7 +291,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -265,7 +308,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -282,7 +325,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -299,7 +342,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -316,7 +359,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -325,7 +368,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
|
||||||
converter := AlipayAppTransactionDataCsvFileImporter
|
importer := AlipayAppTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -346,7 +389,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
|
|||||||
"2024-09-01 23:59:59,Test Category3,充值-普通充值,不计收支,0.05,交易成功,\n")
|
"2024-09-01 23:59:59,Test Category3,充值-普通充值,不计收支,0.05,交易成功,\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 3, len(allNewTransactions))
|
assert.Equal(t, 3, len(allNewTransactions))
|
||||||
@@ -365,7 +408,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T) {
|
||||||
converter := AlipayAppTransactionDataCsvFileImporter
|
importer := AlipayAppTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
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" +
|
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
||||||
"导出交易类型:[全部]\n" +
|
"导出交易类型:[全部]\n" +
|
||||||
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
||||||
"交易时间,商品说明,收/支,金额,收/付款方式,交易状态,\n" +
|
"交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,备注,\n" +
|
||||||
"2024-09-01 03:45:07,余额宝-单次转入,不计收支,0.01,Test Account,交易成功,\n" +
|
"2024-09-01 00:00:00,xxx,xxx-收益发放,不计收支,0.01,Test Account,交易成功,earning,\n" +
|
||||||
"2024-09-01 05:07:29,信用卡还款,不计收支,0.02,Test Account2,交易成功,\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)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 2, len(allNewTransactions))
|
assert.Equal(t, 9, len(allNewTransactions))
|
||||||
assert.Equal(t, 3, len(allNewAccounts))
|
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(1234567890), allNewTransactions[0].Uid)
|
||||||
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
||||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
|
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(1234567890), allNewTransactions[1].Uid)
|
||||||
assert.Equal(t, int64(2), allNewTransactions[1].Amount)
|
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
|
||||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
|
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
|
||||||
assert.Equal(t, "", allNewTransactions[1].OriginalDestinationAccountName)
|
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, int64(1234567890), allNewAccounts[0].Uid)
|
||||||
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
|
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
|
||||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||||
|
|
||||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
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, "CNY", allNewAccounts[1].Currency)
|
||||||
|
|
||||||
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
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, "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) {
|
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -432,7 +547,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -447,7 +562,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(allNewTransactions))
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
@@ -455,7 +570,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_SkipClosedIncomeOrTransferTransaction(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_SkipClosedIncomeOrTransferTransaction(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -472,12 +587,12 @@ func TestAlipayCsvFileImporterParseImportedData_SkipClosedIncomeOrTransferTransa
|
|||||||
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易关闭 ,\n" +
|
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易关闭 ,\n" +
|
||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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)
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownProductTransferTransaction(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownProductTransferTransaction(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -493,12 +608,12 @@ func TestAlipayCsvFileImporterParseImportedData_SkipUnknownProductTransferTransa
|
|||||||
"2024-09-01 23:59:59 ,xxxx ,0.05 ,不计收支 ,交易成功 ,\n" +
|
"2024-09-01 23:59:59 ,xxxx ,0.05 ,不计收支 ,交易成功 ,\n" +
|
||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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)
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -514,12 +629,12 @@ func TestAlipayCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *
|
|||||||
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,xxxx ,\n" +
|
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,xxxx ,\n" +
|
||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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)
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -533,15 +648,15 @@ func TestAlipayCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T)
|
|||||||
"------------------------------------------------------------------------------------\n")
|
"------------------------------------------------------------------------------------\n")
|
||||||
assert.Nil(t, err)
|
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)
|
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)
|
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -557,7 +672,7 @@ func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing
|
|||||||
"金额(元),收/支 ,交易状态 ,\n" +
|
"金额(元),收/支 ,交易状态 ,\n" +
|
||||||
"0.12 ,收入 ,交易成功 ,\n" +
|
"0.12 ,收入 ,交易成功 ,\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.ErrMissingRequiredFieldInHeaderRow.Message)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
// Missing Amount Column
|
// Missing Amount Column
|
||||||
@@ -568,7 +683,7 @@ func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing
|
|||||||
"交易创建时间 ,收/支 ,交易状态 ,\n" +
|
"交易创建时间 ,收/支 ,交易状态 ,\n" +
|
||||||
"2024-09-01 12:34:56 ,收入 ,交易成功 ,\n" +
|
"2024-09-01 12:34:56 ,收入 ,交易成功 ,\n" +
|
||||||
"------------------------------------------------------------------------------------\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)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
// Missing Status Column
|
// Missing Status Column
|
||||||
@@ -579,7 +694,7 @@ func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing
|
|||||||
"交易创建时间 ,金额(元),收/支 ,\n" +
|
"交易创建时间 ,金额(元),收/支 ,\n" +
|
||||||
"2024-09-01 12:34:56 ,0.12 ,收入 ,\n" +
|
"2024-09-01 12:34:56 ,0.12 ,收入 ,\n" +
|
||||||
"------------------------------------------------------------------------------------\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)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
|
|
||||||
// Missing Type Column
|
// Missing Type Column
|
||||||
@@ -590,12 +705,12 @@ func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing
|
|||||||
"交易创建时间 ,金额(元),交易状态 ,\n" +
|
"交易创建时间 ,金额(元),交易状态 ,\n" +
|
||||||
"2024-09-01 12:34:56 ,0.12 ,交易成功 ,\n" +
|
"2024-09-01 12:34:56 ,0.12 ,交易成功 ,\n" +
|
||||||
"------------------------------------------------------------------------------------\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)
|
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlipayCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
|
func TestAlipayCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
|
||||||
converter := AlipayWebTransactionDataCsvFileImporter
|
importer := AlipayWebTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
@@ -609,6 +724,6 @@ func TestAlipayCsvFileImporterParseImportedData_NoTransactionData(t *testing.T)
|
|||||||
"---------------------------------交易记录明细列表------------------------------------\n" +
|
"---------------------------------交易记录明细列表------------------------------------\n" +
|
||||||
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
|
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\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)
|
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,24 +13,31 @@ import (
|
|||||||
|
|
||||||
const alipayTransactionDataStatusSuccessName = "交易成功"
|
const alipayTransactionDataStatusSuccessName = "交易成功"
|
||||||
const alipayTransactionDataStatusPaymentSuccessName = "支付成功"
|
const alipayTransactionDataStatusPaymentSuccessName = "支付成功"
|
||||||
|
const alipayTransactionDataStatusPendingGoodsReceiptConfirmationName = "等待确认收货"
|
||||||
const alipayTransactionDataStatusRepaymentSuccessName = "还款成功"
|
const alipayTransactionDataStatusRepaymentSuccessName = "还款成功"
|
||||||
const alipayTransactionDataStatusClosedName = "交易关闭"
|
const alipayTransactionDataStatusClosedName = "交易关闭"
|
||||||
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
|
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
|
||||||
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
|
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
|
||||||
|
|
||||||
|
const alipayTransactionDataProductNameEarningText = "-收益发放"
|
||||||
|
const alipayTransactionDataProductNamePurchaseInvestmentText = "-买入"
|
||||||
|
const alipayTransactionDataProductNamePurchaseInvestmentRefundText = "-买入退款"
|
||||||
|
const alipayTransactionDataProductNameSellInvestmentRefundText = "-卖出"
|
||||||
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
|
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
|
||||||
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
|
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
|
||||||
const alipayTransactionDataProductNameTransferInText = "转入"
|
const alipayTransactionDataProductNameTransferInText = "转入"
|
||||||
const alipayTransactionDataProductNameTransferOutText = "转出"
|
const alipayTransactionDataProductNameTransferOutText = "转出"
|
||||||
|
const alipayTransactionDataProductNameTransferText = "转账"
|
||||||
const alipayTransactionDataProductNameRepaymentText = "还款"
|
const alipayTransactionDataProductNameRepaymentText = "还款"
|
||||||
|
|
||||||
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
|
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
|
||||||
type alipayTransactionDataRowParser struct {
|
type alipayTransactionDataRowParser struct {
|
||||||
columns alipayTransactionColumnNames
|
columns alipayTransactionColumnNames
|
||||||
|
existedOriginalDataColumns map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse returns the converted transaction data row
|
// Parse returns the converted transaction data row
|
||||||
func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataTable *datatable.CommonTransactionDataTable, dataRow datatable.CommonDataRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
|
func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow datatable.CommonDataTableRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
|
||||||
if dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
|
if dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
|
||||||
dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
|
dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
|
||||||
dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||||
@@ -40,6 +47,7 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
|
|
||||||
if dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusSuccessName &&
|
if dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusSuccessName &&
|
||||||
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusPaymentSuccessName &&
|
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusPaymentSuccessName &&
|
||||||
|
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusPendingGoodsReceiptConfirmationName &&
|
||||||
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRepaymentSuccessName &&
|
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRepaymentSuccessName &&
|
||||||
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusClosedName &&
|
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusClosedName &&
|
||||||
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRefundSuccessName &&
|
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRefundSuccessName &&
|
||||||
@@ -50,23 +58,23 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
|
|
||||||
data := make(map[datatable.TransactionDataTableColumn]string, len(alipayTransactionSupportedColumns))
|
data := make(map[datatable.TransactionDataTableColumn]string, len(alipayTransactionSupportedColumns))
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.timeColumnName) {
|
if p.hasOriginalColumn(p.columns.timeColumnName) {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData(p.columns.timeColumnName)
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData(p.columns.timeColumnName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.categoryColumnName) {
|
if p.hasOriginalColumn(p.columns.categoryColumnName) {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(p.columns.categoryColumnName)
|
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(p.columns.categoryColumnName)
|
||||||
} else {
|
} else {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.amountColumnName) {
|
if p.hasOriginalColumn(p.columns.amountColumnName) {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData(p.columns.amountColumnName)
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData(p.columns.amountColumnName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.descriptionColumnName) && dataRow.GetData(p.columns.descriptionColumnName) != "" {
|
if p.hasOriginalColumn(p.columns.descriptionColumnName) && dataRow.GetData(p.columns.descriptionColumnName) != "" {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(p.columns.descriptionColumnName)
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(p.columns.descriptionColumnName)
|
||||||
} else if dataTable.HasOriginalColumn(p.columns.productNameColumnName) && dataRow.GetData(p.columns.productNameColumnName) != "" {
|
} else if p.hasOriginalColumn(p.columns.productNameColumnName) && dataRow.GetData(p.columns.productNameColumnName) != "" {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(p.columns.productNameColumnName)
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(p.columns.productNameColumnName)
|
||||||
} else {
|
} else {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
|
||||||
@@ -74,13 +82,13 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
|
|
||||||
relatedAccountName := ""
|
relatedAccountName := ""
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.relatedAccountColumnName) {
|
if p.hasOriginalColumn(p.columns.relatedAccountColumnName) {
|
||||||
relatedAccountName = dataRow.GetData(p.columns.relatedAccountColumnName)
|
relatedAccountName = dataRow.GetData(p.columns.relatedAccountColumnName)
|
||||||
}
|
}
|
||||||
|
|
||||||
statusName := ""
|
statusName := ""
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.statusColumnName) {
|
if p.hasOriginalColumn(p.columns.statusColumnName) {
|
||||||
statusName = dataRow.GetData(p.columns.statusColumnName)
|
statusName = dataRow.GetData(p.columns.statusColumnName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +100,7 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
|
|
||||||
localeTextItems := locales.GetLocaleTextItems(locale)
|
localeTextItems := locales.GetLocaleTextItems(locale)
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.typeColumnName) {
|
if p.hasOriginalColumn(p.columns.typeColumnName) {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData(p.columns.typeColumnName)
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData(p.columns.typeColumnName)
|
||||||
|
|
||||||
if dataRow.GetData(p.columns.typeColumnName) == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
|
if dataRow.GetData(p.columns.typeColumnName) == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
|
||||||
@@ -117,20 +125,38 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
targetName := ""
|
targetName := ""
|
||||||
productName := ""
|
productName := ""
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.targetNameColumnName) {
|
if p.hasOriginalColumn(p.columns.targetNameColumnName) {
|
||||||
targetName = dataRow.GetData(p.columns.targetNameColumnName)
|
targetName = dataRow.GetData(p.columns.targetNameColumnName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dataTable.HasOriginalColumn(p.columns.productNameColumnName) {
|
if p.hasOriginalColumn(p.columns.productNameColumnName) {
|
||||||
productName = dataRow.GetData(p.columns.productNameColumnName)
|
productName = dataRow.GetData(p.columns.productNameColumnName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if statusName == alipayTransactionDataStatusRefundSuccessName {
|
if statusName == alipayTransactionDataStatusRefundSuccessName {
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
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_ACCOUNT_NAME] = relatedAccountName
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
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 {
|
} 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_ACCOUNT_NAME] = ""
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet
|
} else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet
|
||||||
@@ -142,6 +168,9 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
|
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
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
|
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||||
@@ -170,9 +199,21 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
|||||||
return data, true, nil
|
return data, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *alipayTransactionDataRowParser) hasOriginalColumn(columnName string) bool {
|
||||||
|
_, exists := p.existedOriginalDataColumns[columnName]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
// createAlipayTransactionDataRowParser returns alipay transaction data row parser
|
// createAlipayTransactionDataRowParser returns alipay transaction data row parser
|
||||||
func createAlipayTransactionDataRowParser(originalColumnNames alipayTransactionColumnNames) datatable.CommonTransactionDataRowParser {
|
func createAlipayTransactionDataRowParser(originalColumnNames alipayTransactionColumnNames, headerColumnNames []string) datatable.CommonTransactionDataRowParser {
|
||||||
|
existedOriginalDataColumns := make(map[string]bool, len(headerColumnNames))
|
||||||
|
|
||||||
|
for i := 0; i < len(headerColumnNames); i++ {
|
||||||
|
existedOriginalDataColumns[headerColumnNames[i]] = true
|
||||||
|
}
|
||||||
|
|
||||||
return &alipayTransactionDataRowParser{
|
return &alipayTransactionDataRowParser{
|
||||||
columns: originalColumnNames,
|
columns: originalColumnNames,
|
||||||
|
existedOriginalDataColumns: existedOriginalDataColumns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ var (
|
|||||||
AlipayWebTransactionDataCsvFileImporter = &alipayWebTransactionDataCsvFileImporter{
|
AlipayWebTransactionDataCsvFileImporter = &alipayWebTransactionDataCsvFileImporter{
|
||||||
alipayTransactionDataCsvFileImporter{
|
alipayTransactionDataCsvFileImporter{
|
||||||
fileHeaderLine: "支付宝交易记录明细查询",
|
fileHeaderLine: "支付宝交易记录明细查询",
|
||||||
dataHeaderStartContent: "交易记录明细列表",
|
dataHeaderStartContent: []string{"交易记录明细列表"},
|
||||||
dataBottomEndLineRune: '-',
|
dataBottomEndLineRune: '-',
|
||||||
originalColumnNames: alipayTransactionColumnNames{
|
originalColumnNames: alipayTransactionColumnNames{
|
||||||
timeColumnName: "交易创建时间",
|
timeColumnName: "交易创建时间",
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package beancount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxAllowedDecimalCount = 6
|
||||||
|
const normalizeFactor = int64(1000000)
|
||||||
|
const normalizedDecimalsMaxZeroString = "000000"
|
||||||
|
const normalizedNumberToAmountFactor = int64(10000) // 1000000 / 100
|
||||||
|
|
||||||
|
var operatorPriority = map[rune]int{
|
||||||
|
'+': 1,
|
||||||
|
'-': 1,
|
||||||
|
'*': 2,
|
||||||
|
'/': 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeNumber(textualNumber string) (*big.Int, error) {
|
||||||
|
decimalSeparatorPos := strings.Index(textualNumber, ".")
|
||||||
|
|
||||||
|
if decimalSeparatorPos < 0 {
|
||||||
|
result := big.NewInt(0)
|
||||||
|
_, ok := result.SetString(textualNumber+normalizedDecimalsMaxZeroString, 10)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
integer := utils.SubString(textualNumber, 0, decimalSeparatorPos)
|
||||||
|
decimals := utils.SubString(textualNumber, decimalSeparatorPos+1, len(textualNumber))
|
||||||
|
|
||||||
|
if len(decimals) > maxAllowedDecimalCount {
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
paddedDecimals := utils.SubString(decimals+normalizedDecimalsMaxZeroString, 0, maxAllowedDecimalCount)
|
||||||
|
result := big.NewInt(0)
|
||||||
|
_, ok := result.SetString(integer+paddedDecimals, 10)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func denormalizeNumberToTextualAmount(num *big.Int) string {
|
||||||
|
result := big.NewInt(0).Add(num, big.NewInt(0)) // make a copy of num
|
||||||
|
result = result.Div(result, big.NewInt(normalizedNumberToAmountFactor))
|
||||||
|
return utils.FormatAmount(result.Int64())
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
|
||||||
|
finalTokens := make([]string, 0)
|
||||||
|
operatorStack := make([]rune, 0)
|
||||||
|
currentNumberBuilder := strings.Builder{}
|
||||||
|
isLastTokenOperator := true
|
||||||
|
|
||||||
|
expr = strings.ReplaceAll(expr, " ", "")
|
||||||
|
|
||||||
|
for i := 0; i < len(expr); i++ {
|
||||||
|
ch := rune(expr[i])
|
||||||
|
|
||||||
|
// number
|
||||||
|
if '0' <= ch && ch <= '9' || ch == '.' {
|
||||||
|
currentNumberBuilder.WriteRune(ch)
|
||||||
|
continue
|
||||||
|
} else if ch == '-' && i+1 < len(expr) && '0' <= expr[i+1] && expr[i+1] <= '9' && currentNumberBuilder.Len() == 0 && isLastTokenOperator {
|
||||||
|
currentNumberBuilder.WriteRune(ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// operator or parenthesis
|
||||||
|
if currentNumberBuilder.Len() > 0 {
|
||||||
|
finalTokens = append(finalTokens, currentNumberBuilder.String())
|
||||||
|
currentNumberBuilder.Reset()
|
||||||
|
isLastTokenOperator = false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ch {
|
||||||
|
case '+', '-', '*', '/':
|
||||||
|
if ch == '-' && isLastTokenOperator {
|
||||||
|
currentNumberBuilder.WriteRune(ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(operatorStack) > 0 {
|
||||||
|
topOperator := operatorStack[len(operatorStack)-1]
|
||||||
|
|
||||||
|
if topOperator == '(' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if operatorPriority[topOperator] >= operatorPriority[ch] {
|
||||||
|
finalTokens = append(finalTokens, string(topOperator))
|
||||||
|
operatorStack = operatorStack[:len(operatorStack)-1]
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorStack = append(operatorStack, ch)
|
||||||
|
isLastTokenOperator = true
|
||||||
|
case '(':
|
||||||
|
operatorStack = append(operatorStack, ch)
|
||||||
|
isLastTokenOperator = true
|
||||||
|
case ')':
|
||||||
|
hasLeftParenthesis := false
|
||||||
|
|
||||||
|
for len(operatorStack) > 0 {
|
||||||
|
topOperator := operatorStack[len(operatorStack)-1]
|
||||||
|
operatorStack = operatorStack[:len(operatorStack)-1]
|
||||||
|
|
||||||
|
if topOperator == '(' {
|
||||||
|
hasLeftParenthesis = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
finalTokens = append(finalTokens, string(topOperator))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasLeftParenthesis {
|
||||||
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because missing left parenthesis", expr)
|
||||||
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
isLastTokenOperator = false
|
||||||
|
default:
|
||||||
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because containing unknown token \"%c\"", expr, ch)
|
||||||
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentNumberBuilder.Len() > 0 {
|
||||||
|
finalTokens = append(finalTokens, currentNumberBuilder.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(operatorStack) > 0 {
|
||||||
|
topOperator := operatorStack[len(operatorStack)-1]
|
||||||
|
operatorStack = operatorStack[:len(operatorStack)-1]
|
||||||
|
|
||||||
|
if topOperator == '(' {
|
||||||
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because missing right parenthesis", expr)
|
||||||
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
finalTokens = append(finalTokens, string(topOperator))
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalTokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluatePostfixExpr(ctx core.Context, tokens []string) (*big.Int, error) {
|
||||||
|
stack := make([]*big.Int, 0)
|
||||||
|
|
||||||
|
for i := 0; i < len(tokens); i++ {
|
||||||
|
token := tokens[i]
|
||||||
|
|
||||||
|
switch token {
|
||||||
|
case "+", "-", "*", "/": // operators
|
||||||
|
if len(stack) < 2 {
|
||||||
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because not enough operands", strings.Join(tokens, " "))
|
||||||
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
// pop the top two operands
|
||||||
|
b := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
|
||||||
|
a := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
|
||||||
|
// evaluate the operation
|
||||||
|
result := big.NewInt(0)
|
||||||
|
switch token {
|
||||||
|
case "+":
|
||||||
|
result.Add(a, b)
|
||||||
|
case "-":
|
||||||
|
result.Sub(a, b)
|
||||||
|
case "*":
|
||||||
|
result.Mul(a, b)
|
||||||
|
result.Div(result, big.NewInt(normalizeFactor))
|
||||||
|
case "/":
|
||||||
|
if b.Int64() == 0 {
|
||||||
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " "))
|
||||||
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
|
}
|
||||||
|
result.Mul(a, big.NewInt(normalizeFactor))
|
||||||
|
result.Div(result, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// push the result back to the stack
|
||||||
|
stack = append(stack, result)
|
||||||
|
default: // operands
|
||||||
|
normalizedNum, err := normalizeNumber(token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because containing invalid number", strings.Join(tokens, " "))
|
||||||
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
stack = append(stack, normalizedNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stack) != 1 {
|
||||||
|
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because missing operator", strings.Join(tokens, " "))
|
||||||
|
return nil, errs.ErrInvalidAmountExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
return stack[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateBeancountAmountExpression(ctx core.Context, expr string) (string, error) {
|
||||||
|
if expr == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
postfixExprTokens, err := toPostfixExprTokens(ctx, expr)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := evaluatePostfixExpr(ctx, postfixExprTokens)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return denormalizeNumberToTextualAmount(result), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package beancount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToPostfixExprTokens_ValidExpression(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
result, err := toPostfixExprTokens(context, "1+2")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"1", "2", "+"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "3-4")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"3", "4", "-"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "5*6")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"5", "6", "*"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "8/2")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"8", "2", "/"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "1+2*3-(4/2)")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "1 + 2 * 3")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"1", "2", "3", "*", "+"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "-1+2")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"-1", "2", "+"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "1.5+2.3")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"1.5", "2.3", "+"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "(1+2)-3")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"1", "2", "+", "3", "-"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "2*-3-3/-2")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"2", "-3", "*", "3", "-2", "/", "-"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "-1.2-3.4*(-5.6/7.8*(9.0-1.2))")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"-1.2", "3.4", "-5.6", "7.8", "/", "9.0", "1.2", "-", "*", "*", "-"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "((((((1+2)*(3+4))))))")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"1", "2", "+", "3", "4", "+", "*"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "(((())))")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "+-*/")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{"-", "*", "/", "+"}, result)
|
||||||
|
|
||||||
|
result, err = toPostfixExprTokens(context, "")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, []string{}, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToPostfixExprTokens_InvalidExpression(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
_, err := toPostfixExprTokens(context, "1=2")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = toPostfixExprTokens(context, "(1")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = toPostfixExprTokens(context, "2)")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = toPostfixExprTokens(context, "((((1+2)))")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = toPostfixExprTokens(context, ")(")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluatePostfixExpr_ValidExpression(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"})
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, big.NewInt(3000000), result)
|
||||||
|
|
||||||
|
result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"})
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, big.NewInt(2000000), result)
|
||||||
|
|
||||||
|
result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"})
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, big.NewInt(12000000), result)
|
||||||
|
|
||||||
|
result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"})
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, big.NewInt(3000000), result)
|
||||||
|
|
||||||
|
result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"})
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, big.NewInt(5000000), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
_, err := evaluatePostfixExpr(context, []string{"1", "0", "/"})
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluatePostfixExpr(context, []string{"1", "+"})
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluatePostfixExpr(context, []string{"1", "="})
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluatePostfixExpr(context, []string{"1", "("})
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluatePostfixExpr(context, []string{"1", ")"})
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluatePostfixExpr(context, []string{"1", "2", "+", "3"})
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluatePostfixExpr(context, []string{"abc"})
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateBeancountAmountExpression_ValidExpression(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
result, err := evaluateBeancountAmountExpression(context, "")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "1+2")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "3.00", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "(1+2)*3")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "9.00", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "-1+2")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "1.00", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "1.5+2.5")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "4.00", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "1+2*3-(4/2)")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "5.00", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "2*-3-3/-2")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "-4.50", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "-1.2-3.4*(-5.6/7.8*(9.0-1.2))")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "17.84", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "(((2+3)))*(((((-5+7)))))")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "10.00", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "3.5+0.1")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "3.60", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "3.55+0.11")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "3.66", result)
|
||||||
|
|
||||||
|
result, err = evaluateBeancountAmountExpression(context, "3.555+0.111")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "3.66", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
_, err := evaluateBeancountAmountExpression(context, "1++2")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "1^2")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "+-*/")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "a+b")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "1/0")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "1+(2*3")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "1+2*3)")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "1+((((2*3)))")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "1+2(3)")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "1)*(2")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "0.abcd+1")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
|
||||||
|
_, err = evaluateBeancountAmountExpression(context, "0.1234567+1")
|
||||||
|
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package beancount
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
const beancountEquityAccountNameOpeningBalance = "Opening-Balances"
|
||||||
|
|
||||||
|
// beancountDirective represents the Beancount directive
|
||||||
|
type beancountDirective string
|
||||||
|
|
||||||
|
// Beancount directives
|
||||||
|
const (
|
||||||
|
beancountDirectiveOpen beancountDirective = "open"
|
||||||
|
beancountDirectiveClose beancountDirective = "close"
|
||||||
|
beancountDirectiveTransaction beancountDirective = "txn"
|
||||||
|
beancountDirectiveCompletedTransaction beancountDirective = "*"
|
||||||
|
beancountDirectiveInCompleteTransaction beancountDirective = "!"
|
||||||
|
beancountDirectivePaddingTransaction beancountDirective = "P"
|
||||||
|
beancountDirectiveCommodity beancountDirective = "commodity"
|
||||||
|
beancountDirectivePrice beancountDirective = "price"
|
||||||
|
beancountDirectiveNote beancountDirective = "note"
|
||||||
|
beancountDirectiveDocument beancountDirective = "document"
|
||||||
|
beancountDirectiveEvent beancountDirective = "event"
|
||||||
|
beancountDirectiveBalance beancountDirective = "balance"
|
||||||
|
beancountDirectivePad beancountDirective = "pad"
|
||||||
|
beancountDirectiveQuery beancountDirective = "query"
|
||||||
|
beancountDirectiveCustom beancountDirective = "custom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// beancountAccountType represents the Beancount account type
|
||||||
|
type beancountAccountType byte
|
||||||
|
|
||||||
|
// Beancount account types
|
||||||
|
const (
|
||||||
|
beancountUnknownAccountType beancountAccountType = 0
|
||||||
|
beancountAssetsAccountType beancountAccountType = 1
|
||||||
|
beancountLiabilitiesAccountType beancountAccountType = 2
|
||||||
|
beancountEquityAccountType beancountAccountType = 3
|
||||||
|
beancountIncomeAccountType beancountAccountType = 4
|
||||||
|
beancountExpensesAccountType beancountAccountType = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// beancountData defines the structure of beancount data
|
||||||
|
type beancountData struct {
|
||||||
|
Accounts map[string]*beancountAccount
|
||||||
|
Transactions []*beancountTransactionEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// beancountAccount defines the structure of beancount account
|
||||||
|
type beancountAccount struct {
|
||||||
|
Name string
|
||||||
|
AccountType beancountAccountType
|
||||||
|
OpenDate string
|
||||||
|
CloseDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
// beancountTransactionEntry defines the structure of beancount transaction entry
|
||||||
|
type beancountTransactionEntry struct {
|
||||||
|
Date string
|
||||||
|
Directive beancountDirective
|
||||||
|
Payee string
|
||||||
|
Narration string
|
||||||
|
Postings []*beancountPosting
|
||||||
|
Tags []string
|
||||||
|
Links []string
|
||||||
|
Metadata map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// beancountPosting defines the structure of beancount transaction posting
|
||||||
|
type beancountPosting struct {
|
||||||
|
Account string
|
||||||
|
Amount string
|
||||||
|
OriginalAmount string
|
||||||
|
Commodity string
|
||||||
|
TotalCost string
|
||||||
|
TotalCostCommodity string
|
||||||
|
Price string
|
||||||
|
PriceCommodity string
|
||||||
|
Metadata map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *beancountAccount) isOpeningBalanceEquityAccount() bool {
|
||||||
|
if a.AccountType != beancountEquityAccountType {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
nameItems := strings.Split(a.Name, string(beancountMetadataKeySuffix))
|
||||||
|
|
||||||
|
if len(nameItems) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return nameItems[1] == beancountEquityAccountNameOpeningBalance
|
||||||
|
}
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
package beancount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const beancountDefaultAssetsAccountTypeName = "Assets"
|
||||||
|
const beancountDefaultLiabilitiesAccountTypeName = "Liabilities"
|
||||||
|
const beancountDefaultEquityAccountTypeName = "Equity"
|
||||||
|
const beancountDefaultIncomeAccountTypeName = "Income"
|
||||||
|
const beancountDefaultExpenseAccountTypeName = "Expenses"
|
||||||
|
|
||||||
|
const beancountOptionAssetsAccountTypeName = "name_assets"
|
||||||
|
const beancountOptionLiabilitiesAccountTypeName = "name_liabilities"
|
||||||
|
const beancountOptionEquityAccountTypeName = "name_equity"
|
||||||
|
const beancountOptionIncomeAccountTypeName = "name_income"
|
||||||
|
const beancountOptionExpenseAccountTypeName = "name_expenses"
|
||||||
|
|
||||||
|
const beancountCommentPrefix = ';'
|
||||||
|
const beancountAccountNameItemsSeparator = ":"
|
||||||
|
const beancountMetadataKeySuffix = ':'
|
||||||
|
const beancountPricePrefix = '@'
|
||||||
|
const beancountLinkPrefix = '^'
|
||||||
|
const beancountTagPrefix = '#'
|
||||||
|
|
||||||
|
// beancountDataReader defines the structure of Beancount data reader
|
||||||
|
type beancountDataReader struct {
|
||||||
|
accountTypeNameMap map[string]beancountAccountType
|
||||||
|
accountTypeNameReversedMap map[beancountAccountType]string
|
||||||
|
allData [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// read returns the imported Beancount data
|
||||||
|
// Reference: https://beancount.github.io/docs/beancount_language_syntax.html
|
||||||
|
func (r *beancountDataReader) read(ctx core.Context) (*beancountData, error) {
|
||||||
|
if len(r.allData) < 1 {
|
||||||
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &beancountData{
|
||||||
|
Accounts: make(map[string]*beancountAccount),
|
||||||
|
Transactions: make([]*beancountTransactionEntry, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var currentTransactionEntry *beancountTransactionEntry
|
||||||
|
var currentTransactionPosting *beancountPosting
|
||||||
|
var currentTags []string
|
||||||
|
|
||||||
|
for i := 0; i < len(r.allData); i++ {
|
||||||
|
items := r.allData[i]
|
||||||
|
|
||||||
|
if len(items) == 0 || (len(items) == 1 && len(items[0]) == 0) || (len(r.getNotEmptyItemByIndex(items, 0)) > 0 && r.getNotEmptyItemByIndex(items, 0)[0] == beancountCommentPrefix) { // skip empty or comment lines
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.getNotEmptyItemsCount(items) < 2 {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because not enough items in line", i, strings.Join(items, " "))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
firstItem := items[0]
|
||||||
|
|
||||||
|
if firstItem == "include" { // not support include directive
|
||||||
|
return nil, errs.ErrBeancountFileNotSupportInclude
|
||||||
|
} else if firstItem == "plugin" { // skip plugin directive lines
|
||||||
|
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
|
||||||
|
continue
|
||||||
|
} else if firstItem == "option" {
|
||||||
|
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
|
||||||
|
r.readAndSetOption(ctx, i, items)
|
||||||
|
continue
|
||||||
|
} else if firstItem == "pushtag" {
|
||||||
|
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
|
||||||
|
currentTags = r.readAndSetTags(ctx, i, items, currentTags, true)
|
||||||
|
continue
|
||||||
|
} else if firstItem == "poptag" {
|
||||||
|
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
|
||||||
|
currentTags = r.readAndSetTags(ctx, i, items, currentTags, false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(firstItem) == 0 { // original line has space prefix, maybe transaction posting or metadata line
|
||||||
|
actualFirstItem := r.getNotEmptyItemByIndex(items, 0)
|
||||||
|
|
||||||
|
if len(actualFirstItem) == 0 { // skip empty lines
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('A' <= actualFirstItem[0] && actualFirstItem[0] <= 'Z') || actualFirstItem[0] == '!' { // transaction posting
|
||||||
|
if currentTransactionEntry != nil && currentTransactionPosting != nil {
|
||||||
|
currentTransactionEntry.Postings = append(currentTransactionEntry.Postings, currentTransactionPosting)
|
||||||
|
currentTransactionPosting = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTransactionPosting, err = r.readTransactionPostingLine(ctx, i, items, data, actualFirstItem[0] == '!')
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if 'a' <= actualFirstItem[0] && actualFirstItem[0] <= 'z' { // metadata
|
||||||
|
metadata := r.readTransactionMetadataLine(ctx, i, items)
|
||||||
|
|
||||||
|
if metadata == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataKey := metadata[0]
|
||||||
|
metadataValue := metadata[1]
|
||||||
|
|
||||||
|
if currentTransactionPosting != nil {
|
||||||
|
if _, exists := currentTransactionPosting.Metadata[metadataKey]; !exists {
|
||||||
|
currentTransactionPosting.Metadata[metadataKey] = metadataValue
|
||||||
|
}
|
||||||
|
} else if currentTransactionEntry != nil {
|
||||||
|
if _, exists := currentTransactionEntry.Metadata[metadataKey]; !exists {
|
||||||
|
currentTransactionEntry.Metadata[metadataKey] = metadataValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because line prefix is invalid", i, strings.Join(items, " "))
|
||||||
|
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if _, err := utils.ParseFromLongDateFirstTime(firstItem, 0); err == nil { // original line has date as first item
|
||||||
|
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
|
||||||
|
|
||||||
|
directive := r.getNotEmptyItemByIndex(items, 1)
|
||||||
|
|
||||||
|
if directive == string(beancountDirectiveOpen) ||
|
||||||
|
directive == string(beancountDirectiveClose) {
|
||||||
|
_, err := r.readAccountLine(ctx, i, items, firstItem, beancountDirective(directive), data)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if directive == string(beancountDirectiveTransaction) ||
|
||||||
|
directive == string(beancountDirectiveCompletedTransaction) ||
|
||||||
|
directive == string(beancountDirectiveInCompleteTransaction) ||
|
||||||
|
directive == string(beancountDirectivePaddingTransaction) {
|
||||||
|
currentTransactionEntry = r.readTransactionLine(ctx, i, items, firstItem, beancountDirective(directive), currentTags)
|
||||||
|
} else if directive == string(beancountDirectiveCommodity) ||
|
||||||
|
directive == string(beancountDirectivePrice) ||
|
||||||
|
directive == string(beancountDirectiveNote) ||
|
||||||
|
directive == string(beancountDirectiveDocument) ||
|
||||||
|
directive == string(beancountDirectiveEvent) ||
|
||||||
|
directive == string(beancountDirectiveBalance) ||
|
||||||
|
directive == string(beancountDirectivePad) ||
|
||||||
|
directive == string(beancountDirectiveQuery) ||
|
||||||
|
directive == string(beancountDirectiveCustom) { // skip commodity / price / note / document / event / balance / pad / query / custom lines
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because directive is unknown", i, strings.Join(items, " "))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else { // first item not start with date or space
|
||||||
|
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentTransactionEntry != nil {
|
||||||
|
if currentTransactionPosting != nil {
|
||||||
|
currentTransactionEntry.Postings = append(currentTransactionEntry.Postings, currentTransactionPosting)
|
||||||
|
currentTransactionPosting = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Transactions = append(data.Transactions, currentTransactionEntry)
|
||||||
|
currentTransactionEntry = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) updateCurrentState(data *beancountData, currentTransactionEntry *beancountTransactionEntry, currentTransactionPosting *beancountPosting) (*beancountTransactionEntry, *beancountPosting) {
|
||||||
|
if currentTransactionEntry != nil {
|
||||||
|
if currentTransactionPosting != nil {
|
||||||
|
currentTransactionEntry.Postings = append(currentTransactionEntry.Postings, currentTransactionPosting)
|
||||||
|
currentTransactionPosting = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Transactions = append(data.Transactions, currentTransactionEntry)
|
||||||
|
currentTransactionEntry = nil
|
||||||
|
currentTransactionPosting = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentTransactionEntry, currentTransactionPosting
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) readAndSetOption(ctx core.Context, lineIndex int, items []string) {
|
||||||
|
if r.getNotEmptyItemsCount(items) != 3 {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readAndSetOption] cannot parse account type name option line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
optionName := r.getNotEmptyItemByIndex(items, 1)
|
||||||
|
optionValue := r.getNotEmptyItemByIndex(items, 2)
|
||||||
|
|
||||||
|
switch optionName {
|
||||||
|
case beancountOptionAssetsAccountTypeName:
|
||||||
|
r.setAccountTypeNameMap(beancountAssetsAccountType, optionValue)
|
||||||
|
break
|
||||||
|
case beancountOptionLiabilitiesAccountTypeName:
|
||||||
|
r.setAccountTypeNameMap(beancountLiabilitiesAccountType, optionValue)
|
||||||
|
break
|
||||||
|
case beancountOptionEquityAccountTypeName:
|
||||||
|
r.setAccountTypeNameMap(beancountEquityAccountType, optionValue)
|
||||||
|
break
|
||||||
|
case beancountOptionIncomeAccountTypeName:
|
||||||
|
r.setAccountTypeNameMap(beancountIncomeAccountType, optionValue)
|
||||||
|
break
|
||||||
|
case beancountOptionExpenseAccountTypeName:
|
||||||
|
r.setAccountTypeNameMap(beancountExpensesAccountType, optionValue)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readAndSetOption] skip option line#%d \"%s\"", lineIndex, strings.Join(items, " "))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) readAndSetTags(ctx core.Context, lineIndex int, items []string, currentTags []string, pushTag bool) []string {
|
||||||
|
if r.getNotEmptyItemsCount(items) != 2 {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readAndSetTags] cannot parse push/pop tag line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
|
||||||
|
return currentTags
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := r.getNotEmptyItemByIndex(items, 1)
|
||||||
|
|
||||||
|
if len(tag) < 2 || tag[0] != beancountTagPrefix {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readAndSetTags] cannot parse push/pop tag line#%d \"%s\", because tag is invalid", lineIndex, strings.Join(items, " "))
|
||||||
|
return currentTags
|
||||||
|
}
|
||||||
|
|
||||||
|
tag = tag[1:]
|
||||||
|
|
||||||
|
if pushTag {
|
||||||
|
for i := 0; i < len(currentTags); i++ {
|
||||||
|
if currentTags[i] == tag {
|
||||||
|
return currentTags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(currentTags, tag)
|
||||||
|
} else { // pop tag
|
||||||
|
for i := 0; i < len(currentTags); i++ {
|
||||||
|
if currentTags[i] == tag {
|
||||||
|
return append(currentTags[:i], currentTags[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentTags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) setAccountTypeNameMap(accountType beancountAccountType, accountTypeName string) {
|
||||||
|
delete(r.accountTypeNameMap, r.accountTypeNameReversedMap[accountType])
|
||||||
|
r.accountTypeNameMap[accountTypeName] = accountType
|
||||||
|
r.accountTypeNameReversedMap[accountType] = accountTypeName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) readAccountLine(ctx core.Context, lineIndex int, items []string, date string, directive beancountDirective, data *beancountData) (*beancountAccount, error) {
|
||||||
|
if r.getNotEmptyItemsCount(items) < 3 {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.parseAccount] cannot parse account line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
accountName := r.getNotEmptyItemByIndex(items, 2)
|
||||||
|
account, exists := data.Accounts[accountName]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
account, err = r.createAccount(ctx, data, accountName)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if directive == beancountDirectiveOpen {
|
||||||
|
account.OpenDate = date
|
||||||
|
return account, nil
|
||||||
|
} else if directive == beancountDirectiveClose {
|
||||||
|
account.CloseDate = date
|
||||||
|
return account, nil
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.parseAccount] cannot parse account line#%d \"%s\", because directive is invalid", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) createAccount(ctx core.Context, data *beancountData, accountName string) (*beancountAccount, error) {
|
||||||
|
account := &beancountAccount{
|
||||||
|
Name: accountName,
|
||||||
|
AccountType: beancountUnknownAccountType,
|
||||||
|
}
|
||||||
|
|
||||||
|
accountNameItems := strings.Split(accountName, beancountAccountNameItemsSeparator)
|
||||||
|
|
||||||
|
if len(accountNameItems) > 1 {
|
||||||
|
accountType, exists := r.accountTypeNameMap[accountNameItems[0]]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
account.AccountType = accountType
|
||||||
|
} else {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.createAccount] cannot parse account \"%s\", because account type \"%s\" is invalid", accountName, accountNameItems[0])
|
||||||
|
return nil, errs.ErrInvalidBeancountFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Accounts[accountName] = account
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) readTransactionLine(ctx core.Context, lineIndex int, items []string, date string, directive beancountDirective, tags []string) *beancountTransactionEntry {
|
||||||
|
transactionEntry := &beancountTransactionEntry{
|
||||||
|
Date: date,
|
||||||
|
Directive: directive,
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
Links: make([]string, 0),
|
||||||
|
Metadata: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionEntry.Tags = append(transactionEntry.Tags, tags...)
|
||||||
|
|
||||||
|
allTags := make(map[string]bool, len(transactionEntry.Tags))
|
||||||
|
|
||||||
|
for _, tag := range transactionEntry.Tags {
|
||||||
|
allTags[tag] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY-MM-DD [txn|Flag] [[Payee] Narration] [#tag] [ˆlink]
|
||||||
|
payeeNarrationFirstIndex := 2
|
||||||
|
payeeNarrationLastIndex := len(items) - 1
|
||||||
|
|
||||||
|
// parse remain items
|
||||||
|
for i := payeeNarrationFirstIndex; i < len(items); i++ {
|
||||||
|
item := items[i]
|
||||||
|
|
||||||
|
if len(item) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if item[0] == beancountCommentPrefix { // ; comment
|
||||||
|
if i-1 < payeeNarrationLastIndex {
|
||||||
|
payeeNarrationLastIndex = i - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if item[0] == beancountTagPrefix { // [#tag]
|
||||||
|
tagName := item[1:]
|
||||||
|
|
||||||
|
if _, exists := allTags[tagName]; !exists {
|
||||||
|
transactionEntry.Tags = append(transactionEntry.Tags, tagName)
|
||||||
|
allTags[tagName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if i-1 < payeeNarrationLastIndex {
|
||||||
|
payeeNarrationLastIndex = i - 1
|
||||||
|
}
|
||||||
|
} else if item[0] == beancountLinkPrefix { // [ˆlink]
|
||||||
|
transactionEntry.Links = append(transactionEntry.Links, item[1:])
|
||||||
|
|
||||||
|
if i-1 < payeeNarrationLastIndex {
|
||||||
|
payeeNarrationLastIndex = i - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if payeeNarrationLastIndex-payeeNarrationFirstIndex >= 1 {
|
||||||
|
transactionEntry.Payee = items[payeeNarrationFirstIndex]
|
||||||
|
transactionEntry.Narration = items[payeeNarrationFirstIndex+1]
|
||||||
|
} else if payeeNarrationLastIndex-payeeNarrationFirstIndex >= 0 {
|
||||||
|
transactionEntry.Narration = items[payeeNarrationFirstIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactionEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) readTransactionPostingLine(ctx core.Context, lineIndex int, items []string, data *beancountData, hasFlag bool) (*beancountPosting, error) {
|
||||||
|
// [Flag] Account Amount [{Cost}] [@ Price]
|
||||||
|
accountNameExpectedIndex := 0
|
||||||
|
|
||||||
|
if hasFlag {
|
||||||
|
accountNameExpectedIndex = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.getNotEmptyItemsCount(items) <= accountNameExpectedIndex {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accountName, accountNameActualIndex := r.getNotEmptyItemAndIndexByIndex(items, accountNameExpectedIndex)
|
||||||
|
|
||||||
|
if accountName == "" || accountNameActualIndex < 0 {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing account name", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil, errs.ErrMissingAccountData
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionPositing := &beancountPosting{
|
||||||
|
Account: accountName,
|
||||||
|
Metadata: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
amountActualLastIndex := -1
|
||||||
|
transactionPositing.OriginalAmount, amountActualLastIndex = r.getOriginalAmountAndLastIndexFromIndex(items, accountNameActualIndex+1)
|
||||||
|
|
||||||
|
if transactionPositing.OriginalAmount == "" || amountActualLastIndex < 0 {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing amount", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
finalAmount, err := evaluateBeancountAmountExpression(ctx, transactionPositing.OriginalAmount)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot evaluate amount expression in line#%d \"%s\", because %s", lineIndex, strings.Join(items, " "), err.Error())
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
} else {
|
||||||
|
transactionPositing.Amount = finalAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
commodityActualIndex := -1
|
||||||
|
transactionPositing.Commodity, commodityActualIndex = r.getNotEmptyItemAndIndexFromIndex(items, amountActualLastIndex+1)
|
||||||
|
|
||||||
|
if transactionPositing.Commodity == "" || commodityActualIndex < 0 {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing commodity", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil, errs.ErrInvalidBeancountFile
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToUpper(transactionPositing.Commodity) != transactionPositing.Commodity { // The syntax for a currency is a word all in capital letters
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because commodity name is not capital letters", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil, errs.ErrInvalidBeancountFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse remain items
|
||||||
|
if commodityActualIndex > 0 {
|
||||||
|
for i := commodityActualIndex + 1; i < len(items); i++ {
|
||||||
|
item := items[i]
|
||||||
|
|
||||||
|
if len(item) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if item[0] == beancountCommentPrefix { // ; comment
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(item) == 2 && item[0] == beancountPricePrefix && item[1] == beancountPricePrefix { // [@@ TotalCost]
|
||||||
|
totalCost, totalCostActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, i+1)
|
||||||
|
|
||||||
|
if totalCostActualIndex > 0 {
|
||||||
|
transactionPositing.TotalCost = totalCost
|
||||||
|
i = totalCostActualIndex
|
||||||
|
|
||||||
|
totalCostCommodity, totalCostCommodityActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, totalCostActualIndex+1)
|
||||||
|
|
||||||
|
if totalCostCommodityActualIndex > 0 {
|
||||||
|
transactionPositing.TotalCostCommodity = totalCostCommodity
|
||||||
|
i = totalCostCommodityActualIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if len(item) == 1 && item[0] == beancountPricePrefix { // [@ Price]
|
||||||
|
price, priceActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, i+1)
|
||||||
|
|
||||||
|
if priceActualIndex > 0 {
|
||||||
|
transactionPositing.Price = price
|
||||||
|
i = priceActualIndex
|
||||||
|
|
||||||
|
priceCommodity, priceCommodityActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, priceActualIndex+1)
|
||||||
|
|
||||||
|
if priceCommodityActualIndex > 0 {
|
||||||
|
transactionPositing.PriceCommodity = priceCommodity
|
||||||
|
i = priceCommodityActualIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if transactionPositing.Account != "" {
|
||||||
|
_, exists := data.Accounts[transactionPositing.Account]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
_, err := r.createAccount(ctx, data, transactionPositing.Account)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactionPositing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) readTransactionMetadataLine(ctx core.Context, lineIndex int, items []string) []string {
|
||||||
|
key := r.getNotEmptyItemByIndex(items, 0)
|
||||||
|
value := r.getNotEmptyItemByIndex(items, 1)
|
||||||
|
|
||||||
|
if key == "" || value == "" {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readTransactionMetadataLine] cannot parse metadata line#%d \"%s\", because key or value is empty", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(key) == 0 || key[len(key)-1] != beancountMetadataKeySuffix {
|
||||||
|
log.Warnf(ctx, "[beancount_data_reader.readTransactionMetadataLine] cannot parse metadata line#%d \"%s\", because key is invalid correct", lineIndex, strings.Join(items, " "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key = key[:len(key)-1]
|
||||||
|
|
||||||
|
return []string{key, value}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) getNotEmptyItemByIndex(items []string, index int) string {
|
||||||
|
item, _ := r.getNotEmptyItemAndIndexByIndex(items, index)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) getNotEmptyItemAndIndexByIndex(items []string, index int) (string, int) {
|
||||||
|
count := -1
|
||||||
|
|
||||||
|
for i := 0; i < len(items); i++ {
|
||||||
|
item := items[i]
|
||||||
|
|
||||||
|
if len(item) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
count++
|
||||||
|
|
||||||
|
if count == index {
|
||||||
|
return items[i], i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) getNotEmptyItemAndIndexFromIndex(items []string, startIndex int) (string, int) {
|
||||||
|
for i := startIndex; i < len(items); i++ {
|
||||||
|
item := items[i]
|
||||||
|
|
||||||
|
if len(item) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, i
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) getNotEmptyItemsCount(items []string) int {
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for i := 0; i < len(items); i++ {
|
||||||
|
if len(items[i]) > 0 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *beancountDataReader) getOriginalAmountAndLastIndexFromIndex(items []string, startIndex int) (string, int) {
|
||||||
|
amountBuilder := strings.Builder{}
|
||||||
|
lastIndex := -1
|
||||||
|
|
||||||
|
for i := startIndex; i < len(items); i++ {
|
||||||
|
item := items[i]
|
||||||
|
|
||||||
|
if len(item) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := true
|
||||||
|
|
||||||
|
// The Amount in “Postings” can also be an arithmetic expression using ( ) * / - +
|
||||||
|
for j := 0; j < len(item); j++ {
|
||||||
|
if !(item[j] >= '0' && item[j] <= '9') && item[j] != '.' && item[j] != '(' && item[j] != ')' &&
|
||||||
|
item[j] != '*' && item[j] != '/' && item[j] != '-' && item[j] != '+' {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if amountBuilder.Len() > 0 {
|
||||||
|
amountBuilder.WriteRune(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
amountBuilder.WriteString(item)
|
||||||
|
lastIndex = i
|
||||||
|
}
|
||||||
|
|
||||||
|
return amountBuilder.String(), lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNewBeancountDataReader(ctx core.Context, data []byte) (*beancountDataReader, error) {
|
||||||
|
fallback := unicode.UTF8.NewDecoder()
|
||||||
|
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
||||||
|
csvReader := csv.NewReader(reader)
|
||||||
|
csvReader.Comma = ' '
|
||||||
|
csvReader.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
allData := make([][]string, 0)
|
||||||
|
|
||||||
|
for {
|
||||||
|
items, err := csvReader.Read()
|
||||||
|
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(ctx, "[beancount_data_reader.createNewBeancountDataReader] cannot parse data, because %s", err.Error())
|
||||||
|
return nil, errs.ErrInvalidBeancountFile
|
||||||
|
}
|
||||||
|
|
||||||
|
allData = append(allData, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &beancountDataReader{
|
||||||
|
accountTypeNameMap: map[string]beancountAccountType{
|
||||||
|
beancountDefaultAssetsAccountTypeName: beancountAssetsAccountType,
|
||||||
|
beancountDefaultLiabilitiesAccountTypeName: beancountLiabilitiesAccountType,
|
||||||
|
beancountDefaultEquityAccountTypeName: beancountEquityAccountType,
|
||||||
|
beancountDefaultIncomeAccountTypeName: beancountIncomeAccountType,
|
||||||
|
beancountDefaultExpenseAccountTypeName: beancountExpensesAccountType,
|
||||||
|
},
|
||||||
|
accountTypeNameReversedMap: map[beancountAccountType]string{
|
||||||
|
beancountAssetsAccountType: beancountDefaultAssetsAccountTypeName,
|
||||||
|
beancountLiabilitiesAccountType: beancountDefaultLiabilitiesAccountTypeName,
|
||||||
|
beancountEquityAccountType: beancountDefaultEquityAccountTypeName,
|
||||||
|
beancountIncomeAccountType: beancountDefaultIncomeAccountTypeName,
|
||||||
|
beancountExpensesAccountType: beancountDefaultExpenseAccountTypeName,
|
||||||
|
},
|
||||||
|
allData: allData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
package beancount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBeancountDataReaderRead(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"; Test Beancount Data\n"+
|
||||||
|
"option \"name_assets\" \"AssetsAccount\"\n"+
|
||||||
|
"option \"name_liabilities\" \"LiabilitiesAccount\"\n"+
|
||||||
|
"option \"name_equity\" \"EquityAccount\"\n"+
|
||||||
|
"option \"name_income\" \"IncomeAccount\"\n"+
|
||||||
|
"option \"name_expenses\" \"ExpensesAccount\"\n"+
|
||||||
|
"\n"+
|
||||||
|
"2024-01-01 open AssetsAccount:TestAccount\n"+
|
||||||
|
"2024-01-02 open LiabilitiesAccount:TestAccount2\n"+
|
||||||
|
"2024-01-03 open EquityAccount:Opening-Balances\n"+
|
||||||
|
"\n"+
|
||||||
|
"; The following transactions with tag1 and tag2\n"+
|
||||||
|
"pushtag #tag1\n"+
|
||||||
|
"pushtag #tag2\n"+
|
||||||
|
"\n"+
|
||||||
|
"2024-01-05 * \"Payee Name\" \"Foo Bar\" #tag3 #tag4 ^test-link\n"+
|
||||||
|
" IncomeAccount:TestCategory -123.45 CNY\n"+
|
||||||
|
" AssetsAccount:TestAccount 123.45 CNY\n"+
|
||||||
|
"; The following transactions with tag2\n"+
|
||||||
|
"poptag #tag1\n"+
|
||||||
|
"2024-01-06 * \"test\n#test2\" #tag5 #tag6 ^test-link2\n"+
|
||||||
|
" LiabilitiesAccount:TestAccount2 -0.12 USD\n"+
|
||||||
|
" ExpensesAccount:TestCategory2 0.12 USD\n"+
|
||||||
|
"2024-01-07 close AssetsAccount:TestAccount\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 5, len(actualData.Accounts))
|
||||||
|
assert.Equal(t, "AssetsAccount:TestAccount", actualData.Accounts["AssetsAccount:TestAccount"].Name)
|
||||||
|
assert.Equal(t, beancountAssetsAccountType, actualData.Accounts["AssetsAccount:TestAccount"].AccountType)
|
||||||
|
assert.Equal(t, "2024-01-01", actualData.Accounts["AssetsAccount:TestAccount"].OpenDate)
|
||||||
|
assert.Equal(t, "2024-01-07", actualData.Accounts["AssetsAccount:TestAccount"].CloseDate)
|
||||||
|
|
||||||
|
assert.Equal(t, "LiabilitiesAccount:TestAccount2", actualData.Accounts["LiabilitiesAccount:TestAccount2"].Name)
|
||||||
|
assert.Equal(t, beancountLiabilitiesAccountType, actualData.Accounts["LiabilitiesAccount:TestAccount2"].AccountType)
|
||||||
|
assert.Equal(t, "2024-01-02", actualData.Accounts["LiabilitiesAccount:TestAccount2"].OpenDate)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions))
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-05", actualData.Transactions[0].Date)
|
||||||
|
assert.Equal(t, "Payee Name", actualData.Transactions[0].Payee)
|
||||||
|
assert.Equal(t, "Foo Bar", actualData.Transactions[0].Narration)
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[0].Postings))
|
||||||
|
assert.Equal(t, "IncomeAccount:TestCategory", actualData.Transactions[0].Postings[0].Account)
|
||||||
|
assert.Equal(t, "-123.45", actualData.Transactions[0].Postings[0].Amount)
|
||||||
|
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[0].Commodity)
|
||||||
|
assert.Equal(t, "AssetsAccount:TestAccount", actualData.Transactions[0].Postings[1].Account)
|
||||||
|
assert.Equal(t, "123.45", actualData.Transactions[0].Postings[1].Amount)
|
||||||
|
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[1].Commodity)
|
||||||
|
|
||||||
|
assert.Equal(t, 4, len(actualData.Transactions[0].Tags))
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Tags[0], "tag1")
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Tags[1], "tag2")
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Tags[2], "tag3")
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Tags[3], "tag4")
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(actualData.Transactions[0].Links))
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Links[0], "test-link")
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-06", actualData.Transactions[1].Date)
|
||||||
|
assert.Equal(t, "", actualData.Transactions[1].Payee)
|
||||||
|
assert.Equal(t, "test\n#test2", actualData.Transactions[1].Narration)
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[1].Postings))
|
||||||
|
assert.Equal(t, "LiabilitiesAccount:TestAccount2", actualData.Transactions[1].Postings[0].Account)
|
||||||
|
assert.Equal(t, "-0.12", actualData.Transactions[1].Postings[0].Amount)
|
||||||
|
assert.Equal(t, "USD", actualData.Transactions[1].Postings[0].Commodity)
|
||||||
|
assert.Equal(t, "ExpensesAccount:TestCategory2", actualData.Transactions[1].Postings[1].Account)
|
||||||
|
assert.Equal(t, "0.12", actualData.Transactions[1].Postings[1].Amount)
|
||||||
|
assert.Equal(t, "USD", actualData.Transactions[1].Postings[1].Commodity)
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(actualData.Transactions[1].Tags))
|
||||||
|
assert.Equal(t, actualData.Transactions[1].Tags[0], "tag2")
|
||||||
|
assert.Equal(t, actualData.Transactions[1].Tags[1], "tag5")
|
||||||
|
assert.Equal(t, actualData.Transactions[1].Tags[2], "tag6")
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(actualData.Transactions[1].Links))
|
||||||
|
assert.Equal(t, actualData.Transactions[1].Links[0], "test-link2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderRead_EmptyContent(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderRead_UnsupportedInclude(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte("include \"other.beancount\""))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrBeancountFileNotSupportInclude.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderRead_SkipUnsupportedDirective(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"plugin \"beancount.plugins.plugin_name\"\n"+
|
||||||
|
"unknown directive\n"+
|
||||||
|
"2024-01-01 commodity USD\n"+
|
||||||
|
"2024-01-01 price USD 1.08 CAD\n"+
|
||||||
|
"2024-01-01 note Assets:Test \"some text\"\n"+
|
||||||
|
"2024-01-01 document Assets:Test \"scheme://path\"\n"+
|
||||||
|
"2024-01-01 event \"location\" \"address\"\n"+
|
||||||
|
"2024-01-01 balance Assets:Test 100.00 USD\n"+
|
||||||
|
"2024-01-01 pad Assets:Test Equity:Opening-Balances\n"+
|
||||||
|
"2024-01-01 query \"Name\" \"\nSELECT FIELDS FROM TABLE\"\n"+
|
||||||
|
"2024-01-01 custom \"Type\" \"Value\"\n"+
|
||||||
|
"2024-01-01 unknown directive\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadAndSetOption_AccountTypeName(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"option \"name_assets\" \"A\"\n"+
|
||||||
|
"option \"name_liabilities\" \"L\"\n"+
|
||||||
|
"option \"name_equity\" \"E\"\n"+
|
||||||
|
"\n"+
|
||||||
|
"2024-01-01 open A:TestAccount\n"+
|
||||||
|
"2024-01-02 open L:TestAccount2\n"+
|
||||||
|
"2024-01-03 open E:Opening-Balances\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(actualData.Accounts))
|
||||||
|
|
||||||
|
assert.Equal(t, "A:TestAccount", actualData.Accounts["A:TestAccount"].Name)
|
||||||
|
assert.Equal(t, beancountAssetsAccountType, actualData.Accounts["A:TestAccount"].AccountType)
|
||||||
|
|
||||||
|
assert.Equal(t, "L:TestAccount2", actualData.Accounts["L:TestAccount2"].Name)
|
||||||
|
assert.Equal(t, beancountLiabilitiesAccountType, actualData.Accounts["L:TestAccount2"].AccountType)
|
||||||
|
|
||||||
|
assert.Equal(t, "E:Opening-Balances", actualData.Accounts["E:Opening-Balances"].Name)
|
||||||
|
assert.Equal(t, beancountEquityAccountType, actualData.Accounts["E:Opening-Balances"].AccountType)
|
||||||
|
assert.True(t, actualData.Accounts["E:Opening-Balances"].isOpeningBalanceEquityAccount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadAndSetOption_InvalidLineOrUnsupportedOption(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"option \"test\" \"Test\" \"Test2\"\n"+
|
||||||
|
"option \"test\" \"Test\"\n"+
|
||||||
|
"option \"test\"\n"+
|
||||||
|
"option \n"+
|
||||||
|
"option\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadAndSetTags(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"pushtag #tag1\n"+
|
||||||
|
"pushtag #tag2\n"+
|
||||||
|
"pushtag #tag2\n"+
|
||||||
|
"pushtag #tag1\n"+
|
||||||
|
"\n"+
|
||||||
|
"2024-01-01 * #tag3 #tag4\n"+
|
||||||
|
"poptag #tag1\n"+
|
||||||
|
"poptag #tag2\n"+
|
||||||
|
"pushtag\n"+
|
||||||
|
"pushtag \n"+
|
||||||
|
"pushtag tag\n"+
|
||||||
|
"2024-01-02 * #tag5 #tag6\n"+
|
||||||
|
"poptag #tag1\n"+
|
||||||
|
"poptag #tag2\n"+
|
||||||
|
"poptag\n"+
|
||||||
|
"poptag \n"+
|
||||||
|
"2024-01-03 * #tag5 #tag6\n"+
|
||||||
|
"pushtag #tag3\n"+
|
||||||
|
"pushtag #tag6\n"+
|
||||||
|
"2024-01-04 * #tag5 #tag6\n"+
|
||||||
|
"2024-01-05 * #tag5 #tag6 #tag6 #tag5\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 5, len(actualData.Transactions))
|
||||||
|
|
||||||
|
assert.Equal(t, 4, len(actualData.Transactions[0].Tags))
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Tags[0], "tag1")
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Tags[1], "tag2")
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Tags[2], "tag3")
|
||||||
|
assert.Equal(t, actualData.Transactions[0].Tags[3], "tag4")
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[1].Tags))
|
||||||
|
assert.Equal(t, actualData.Transactions[1].Tags[0], "tag5")
|
||||||
|
assert.Equal(t, actualData.Transactions[1].Tags[1], "tag6")
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[2].Tags))
|
||||||
|
assert.Equal(t, actualData.Transactions[2].Tags[0], "tag5")
|
||||||
|
assert.Equal(t, actualData.Transactions[2].Tags[1], "tag6")
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(actualData.Transactions[3].Tags))
|
||||||
|
assert.Equal(t, actualData.Transactions[3].Tags[0], "tag3")
|
||||||
|
assert.Equal(t, actualData.Transactions[3].Tags[1], "tag6")
|
||||||
|
assert.Equal(t, actualData.Transactions[3].Tags[2], "tag5")
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(actualData.Transactions[4].Tags))
|
||||||
|
assert.Equal(t, actualData.Transactions[4].Tags[0], "tag3")
|
||||||
|
assert.Equal(t, actualData.Transactions[4].Tags[1], "tag6")
|
||||||
|
assert.Equal(t, actualData.Transactions[4].Tags[2], "tag5")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadAccountLine_InvalidLine(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 open\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 0, len(actualData.Accounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadAccountLine_InvalidAccountType(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 open Test:TestAccount\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
|
||||||
|
reader, err = createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"option \"name_assets\" \"A\"\n"+
|
||||||
|
"\n"+
|
||||||
|
"2024-01-01 open Assets:TestAccount\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionLine(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
"2024-01-02 * \"test\ttest2\ntest3\" ; some comment\n"+
|
||||||
|
"2024-01-03 ! \"test\" \"test2\"\n"+
|
||||||
|
"2024-01-04 P \"test\" #tag #tag2 ; some comment\n"+
|
||||||
|
"2024-01-05 txn \"test\" ^scheme://path/to/test/link ; some comment\n"+
|
||||||
|
"2024-01-06 txn ; \"test\" \"test2\" #tag ^link\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 6, len(actualData.Transactions))
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-01", actualData.Transactions[0].Date)
|
||||||
|
assert.Equal(t, beancountDirectiveCompletedTransaction, actualData.Transactions[0].Directive)
|
||||||
|
assert.Equal(t, "", actualData.Transactions[0].Payee)
|
||||||
|
assert.Equal(t, "", actualData.Transactions[0].Narration)
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-02", actualData.Transactions[1].Date)
|
||||||
|
assert.Equal(t, beancountDirectiveCompletedTransaction, actualData.Transactions[1].Directive)
|
||||||
|
assert.Equal(t, "", actualData.Transactions[1].Payee)
|
||||||
|
assert.Equal(t, "test\ttest2\ntest3", actualData.Transactions[1].Narration)
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-03", actualData.Transactions[2].Date)
|
||||||
|
assert.Equal(t, beancountDirectiveInCompleteTransaction, actualData.Transactions[2].Directive)
|
||||||
|
assert.Equal(t, "test", actualData.Transactions[2].Payee)
|
||||||
|
assert.Equal(t, "test2", actualData.Transactions[2].Narration)
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-04", actualData.Transactions[3].Date)
|
||||||
|
assert.Equal(t, beancountDirectivePaddingTransaction, actualData.Transactions[3].Directive)
|
||||||
|
assert.Equal(t, "", actualData.Transactions[3].Payee)
|
||||||
|
assert.Equal(t, "test", actualData.Transactions[3].Narration)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[3].Tags))
|
||||||
|
assert.Equal(t, actualData.Transactions[3].Tags[0], "tag")
|
||||||
|
assert.Equal(t, actualData.Transactions[3].Tags[1], "tag2")
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-05", actualData.Transactions[4].Date)
|
||||||
|
assert.Equal(t, beancountDirectiveTransaction, actualData.Transactions[4].Directive)
|
||||||
|
assert.Equal(t, "", actualData.Transactions[4].Payee)
|
||||||
|
assert.Equal(t, "test", actualData.Transactions[4].Narration)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(actualData.Transactions[4].Links))
|
||||||
|
assert.Equal(t, actualData.Transactions[4].Links[0], "scheme://path/to/test/link")
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-06", actualData.Transactions[5].Date)
|
||||||
|
assert.Equal(t, beancountDirectiveTransaction, actualData.Transactions[5].Directive)
|
||||||
|
assert.Equal(t, "", actualData.Transactions[5].Payee)
|
||||||
|
assert.Equal(t, "", actualData.Transactions[5].Narration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionPostingLine(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Income:TestCategory -123.45 CNY ; some comment\n"+
|
||||||
|
" Assets:TestAccount 123.45 CNY\n"+
|
||||||
|
"2024-01-02 *\n"+
|
||||||
|
" Liabilities:TestAccount2 -0.23 USD ; some comment\n"+
|
||||||
|
" Expenses:TestCategory2 0.12 USD @@ 0.84 CNY\n"+
|
||||||
|
" Expenses:TestCategory3 0.11 USD @ 7.12 CNY\n"+
|
||||||
|
" ! Expenses:TestCategory4 0.00 USD {0.00 CNY}\n"+
|
||||||
|
" Expenses:TestCategory5 \n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions))
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-01", actualData.Transactions[0].Date)
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[0].Postings))
|
||||||
|
assert.Equal(t, "Income:TestCategory", actualData.Transactions[0].Postings[0].Account)
|
||||||
|
assert.Equal(t, "-123.45", actualData.Transactions[0].Postings[0].Amount)
|
||||||
|
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[0].Commodity)
|
||||||
|
|
||||||
|
assert.Equal(t, "Assets:TestAccount", actualData.Transactions[0].Postings[1].Account)
|
||||||
|
assert.Equal(t, "123.45", actualData.Transactions[0].Postings[1].Amount)
|
||||||
|
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[1].Commodity)
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-02", actualData.Transactions[1].Date)
|
||||||
|
assert.Equal(t, 4, len(actualData.Transactions[1].Postings))
|
||||||
|
|
||||||
|
assert.Equal(t, "Liabilities:TestAccount2", actualData.Transactions[1].Postings[0].Account)
|
||||||
|
assert.Equal(t, "-0.23", actualData.Transactions[1].Postings[0].Amount)
|
||||||
|
assert.Equal(t, "USD", actualData.Transactions[1].Postings[0].Commodity)
|
||||||
|
assert.Equal(t, "Expenses:TestCategory2", actualData.Transactions[1].Postings[1].Account)
|
||||||
|
|
||||||
|
assert.Equal(t, "0.12", actualData.Transactions[1].Postings[1].Amount)
|
||||||
|
assert.Equal(t, "USD", actualData.Transactions[1].Postings[1].Commodity)
|
||||||
|
assert.Equal(t, "0.84", actualData.Transactions[1].Postings[1].TotalCost)
|
||||||
|
assert.Equal(t, "CNY", actualData.Transactions[1].Postings[1].TotalCostCommodity)
|
||||||
|
assert.Equal(t, "Expenses:TestCategory3", actualData.Transactions[1].Postings[2].Account)
|
||||||
|
|
||||||
|
assert.Equal(t, "0.11", actualData.Transactions[1].Postings[2].Amount)
|
||||||
|
assert.Equal(t, "USD", actualData.Transactions[1].Postings[2].Commodity)
|
||||||
|
assert.Equal(t, "7.12", actualData.Transactions[1].Postings[2].Price)
|
||||||
|
assert.Equal(t, "CNY", actualData.Transactions[1].Postings[2].PriceCommodity)
|
||||||
|
|
||||||
|
assert.Equal(t, "0.00", actualData.Transactions[1].Postings[3].Amount)
|
||||||
|
assert.Equal(t, "USD", actualData.Transactions[1].Postings[3].Commodity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionPostingLine_AmountExpression(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Income:TestCategory (1.2-3.4) * 5.6 / 7.8 CNY\n"+
|
||||||
|
" Assets:TestAccount 1.2 * 3.4/-5.6 - 7.8 CNY\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(actualData.Transactions))
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-01", actualData.Transactions[0].Date)
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[0].Postings))
|
||||||
|
assert.Equal(t, "Income:TestCategory", actualData.Transactions[0].Postings[0].Account)
|
||||||
|
assert.Equal(t, "(1.2-3.4) * 5.6 / 7.8", actualData.Transactions[0].Postings[0].OriginalAmount)
|
||||||
|
assert.Equal(t, "-1.58", actualData.Transactions[0].Postings[0].Amount)
|
||||||
|
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[0].Commodity)
|
||||||
|
|
||||||
|
assert.Equal(t, "Assets:TestAccount", actualData.Transactions[0].Postings[1].Account)
|
||||||
|
assert.Equal(t, "1.2 * 3.4/-5.6 - 7.8", actualData.Transactions[0].Postings[1].OriginalAmount)
|
||||||
|
assert.Equal(t, "-8.53", actualData.Transactions[0].Postings[1].Amount)
|
||||||
|
assert.Equal(t, "CNY", actualData.Transactions[0].Postings[1].Commodity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionPostingLine_InvalidAmountExpression(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Income:TestCategory (1.2-3.4)*5.6/0 CNY\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
|
||||||
|
reader, err = createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Assets:TestAccount abc CNY\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionPostingLine_InvalidAccountType(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Income:TestCategory -123.45 CNY\n"+
|
||||||
|
" Test:TestAccount 123.45 CNY\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionPostingLine_InvalidCommodity(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Income:TestCategory -123.45 cny\n"+
|
||||||
|
" Assets:TestAccount 123.45 cny\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionPostingLine_MissingAmount(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Assets:TestAccount\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 1, len(actualData.Transactions))
|
||||||
|
assert.Equal(t, 0, len(actualData.Transactions[0].Postings))
|
||||||
|
|
||||||
|
reader, err = createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Assets:TestAccount \n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err = reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 1, len(actualData.Transactions))
|
||||||
|
assert.Equal(t, 0, len(actualData.Transactions[0].Postings))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionPostingLine_MissingCommodity(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Assets:TestAccount 123.45\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
|
||||||
|
reader, err = createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" Assets:TestAccount 123.45 \n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = reader.read(context)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountDataReaderReadTransactionMetadataLine(t *testing.T) {
|
||||||
|
context := core.NewNullContext()
|
||||||
|
reader, err := createNewBeancountDataReader(context, []byte(""+
|
||||||
|
"2024-01-01 *\n"+
|
||||||
|
" key: value\n"+
|
||||||
|
" key2: \"value 2\"\n"+
|
||||||
|
" key3: \n"+
|
||||||
|
" key4: \"\"\n"+
|
||||||
|
" key5 : \"\"\n"+
|
||||||
|
" key2: \"new value\"\n"+
|
||||||
|
" Income:TestCategory -123.45 CNY\n"+
|
||||||
|
" Assets:TestAccount 123.45 CNY\n"+
|
||||||
|
"2024-01-02 *\n"+
|
||||||
|
" Liabilities:TestAccount2 -0.23 USD\n"+
|
||||||
|
" key6: value6\n"+
|
||||||
|
" key7: \"value 7\"\n"+
|
||||||
|
" key8: \n"+
|
||||||
|
" key9: \"\"\n"+
|
||||||
|
" key0 : \"\"\n"+
|
||||||
|
" key6: \"new value\"\n"+
|
||||||
|
" Expenses:TestCategory2 0.12 USD\n"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
actualData, err := reader.read(context)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions))
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-01", actualData.Transactions[0].Date)
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[0].Postings))
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[0].Metadata))
|
||||||
|
assert.Equal(t, "value", actualData.Transactions[0].Metadata["key"])
|
||||||
|
assert.Equal(t, "value 2", actualData.Transactions[0].Metadata["key2"])
|
||||||
|
|
||||||
|
assert.Equal(t, "2024-01-02", actualData.Transactions[1].Date)
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[1].Postings))
|
||||||
|
assert.Equal(t, 2, len(actualData.Transactions[1].Postings[0].Metadata))
|
||||||
|
assert.Equal(t, "value6", actualData.Transactions[1].Postings[0].Metadata["key6"])
|
||||||
|
assert.Equal(t, "value 7", actualData.Transactions[1].Postings[0].Metadata["key7"])
|
||||||
|
assert.Equal(t, 0, len(actualData.Transactions[1].Postings[1].Metadata))
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package beancount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBeancountAccount_IsOpeningBalanceEquityAccount_True(t *testing.T) {
|
||||||
|
account := beancountAccount{
|
||||||
|
AccountType: beancountEquityAccountType,
|
||||||
|
Name: "Equity:Opening-Balances",
|
||||||
|
}
|
||||||
|
assert.True(t, account.isOpeningBalanceEquityAccount())
|
||||||
|
|
||||||
|
account = beancountAccount{
|
||||||
|
AccountType: beancountEquityAccountType,
|
||||||
|
Name: "E:Opening-Balances",
|
||||||
|
}
|
||||||
|
assert.True(t, account.isOpeningBalanceEquityAccount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountAccount_IsOpeningBalanceEquityAccount_False(t *testing.T) {
|
||||||
|
account := beancountAccount{
|
||||||
|
AccountType: beancountAssetsAccountType,
|
||||||
|
Name: "Equity:Opening-Balances",
|
||||||
|
}
|
||||||
|
assert.False(t, account.isOpeningBalanceEquityAccount())
|
||||||
|
|
||||||
|
account = beancountAccount{
|
||||||
|
AccountType: beancountEquityAccountType,
|
||||||
|
Name: "Opening-Balances",
|
||||||
|
}
|
||||||
|
assert.False(t, account.isOpeningBalanceEquityAccount())
|
||||||
|
|
||||||
|
account = beancountAccount{
|
||||||
|
AccountType: beancountEquityAccountType,
|
||||||
|
Name: "Equity:Other",
|
||||||
|
}
|
||||||
|
assert.False(t, account.isOpeningBalanceEquityAccount())
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package beancount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var beancountTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||||
|
models.TRANSACTION_TYPE_MODIFY_BALANCE: utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)),
|
||||||
|
models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)),
|
||||||
|
models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)),
|
||||||
|
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// beancountTransactionDataImporter defines the structure of Beancount importer for transaction data
|
||||||
|
type beancountTransactionDataImporter struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a beancount transaction data importer singleton instance
|
||||||
|
var (
|
||||||
|
BeancountTransactionDataImporter = &beancountTransactionDataImporter{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseImportedData returns the imported data by parsing the Beancount transaction data
|
||||||
|
func (c *beancountTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, 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 {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
beancountData, err := beancountDataReader.read(ctx)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDataTable, err := createNewBeancountTransactionDataTable(beancountData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(beancountTransactionTypeNameMapping, "", "", BEANCOUNT_TRANSACTION_TAG_SEPARATOR)
|
||||||
|
|
||||||
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
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"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBeancountTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
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"+
|
||||||
|
"2024-09-02 *\n"+
|
||||||
|
" Income:TestCategory -0.12 CNY\n"+
|
||||||
|
" Assets:TestAccount 0.12 CNY\n"+
|
||||||
|
"2024-09-03 *\n"+
|
||||||
|
" Assets:TestAccount -1.00 CNY\n"+
|
||||||
|
" Expenses:TestCategory2 1.00 CNY\n"+
|
||||||
|
"2024-09-04 *\n"+
|
||||||
|
" Assets:TestAccount -0.05 CNY\n"+
|
||||||
|
" Assets:TestAccount2 0.05 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 4, len(allNewTransactions))
|
||||||
|
assert.Equal(t, 2, len(allNewAccounts))
|
||||||
|
assert.Equal(t, 1, len(allNewSubExpenseCategories))
|
||||||
|
assert.Equal(t, 1, len(allNewSubIncomeCategories))
|
||||||
|
assert.Equal(t, 1, len(allNewSubTransferCategories))
|
||||||
|
assert.Equal(t, 0, len(allNewTags))
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
|
||||||
|
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||||
|
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type)
|
||||||
|
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
|
||||||
|
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[1].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Income:TestCategory", allNewTransactions[1].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
|
||||||
|
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||||
|
assert.Equal(t, int64(100), allNewTransactions[2].Amount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[2].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Expenses:TestCategory2", allNewTransactions[2].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||||
|
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
|
||||||
|
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[3].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[3].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||||
|
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
|
||||||
|
assert.Equal(t, "Expenses:TestCategory2", allNewSubExpenseCategories[0].Name)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
|
||||||
|
assert.Equal(t, "Income:TestCategory", allNewSubIncomeCategories[0].Name)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
|
||||||
|
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountTransactionDataFileParseImportedData_MinimumValidData2(t *testing.T) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
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"+
|
||||||
|
"2024-09-02 *\n"+
|
||||||
|
" Assets:TestAccount 0.12 CNY\n"+
|
||||||
|
" Income:TestCategory -0.12 CNY\n"+
|
||||||
|
"2024-09-03 *\n"+
|
||||||
|
" Expenses:TestCategory2 1.00 CNY\n"+
|
||||||
|
" Assets:TestAccount -1.00 CNY\n"+
|
||||||
|
"2024-09-04 *\n"+
|
||||||
|
" Assets:TestAccount2 0.05 CNY\n"+
|
||||||
|
" Assets:TestAccount -0.05 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 4, len(allNewTransactions))
|
||||||
|
assert.Equal(t, 2, len(allNewAccounts))
|
||||||
|
assert.Equal(t, 1, len(allNewSubExpenseCategories))
|
||||||
|
assert.Equal(t, 1, len(allNewSubIncomeCategories))
|
||||||
|
assert.Equal(t, 1, len(allNewSubTransferCategories))
|
||||||
|
assert.Equal(t, 0, len(allNewTags))
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
|
||||||
|
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||||
|
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type)
|
||||||
|
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
|
||||||
|
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[1].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Income:TestCategory", allNewTransactions[1].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
|
||||||
|
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||||
|
assert.Equal(t, int64(100), allNewTransactions[2].Amount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[2].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Expenses:TestCategory2", allNewTransactions[2].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||||
|
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||||
|
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
|
||||||
|
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[3].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[3].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||||
|
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
|
||||||
|
assert.Equal(t, "Expenses:TestCategory2", allNewSubExpenseCategories[0].Name)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
|
||||||
|
assert.Equal(t, "Income:TestCategory", allNewSubIncomeCategories[0].Name)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
|
||||||
|
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(
|
||||||
|
"2024/09/01 *\n"+
|
||||||
|
" Equity:Opening-Balances -123.45 CNY\n"+
|
||||||
|
" 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) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
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"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, 2, len(allNewAccounts))
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||||
|
assert.Equal(t, int64(12), allNewTransactions[0].Amount)
|
||||||
|
assert.Equal(t, int64(84), allNewTransactions[0].RelatedAccountAmount)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewTransactions[0].OriginalSourceAccountName)
|
||||||
|
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||||
|
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[0].OriginalDestinationAccountName)
|
||||||
|
assert.Equal(t, "CNY", allNewTransactions[0].OriginalDestinationAccountCurrency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||||
|
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
|
||||||
|
assert.Equal(t, "USD", allNewAccounts[0].Currency)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||||
|
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
|
||||||
|
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountTransactionDataFileParseImportedData_ParseInvalidAmount(t *testing.T) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(
|
||||||
|
"2024-09-01 *\n"+
|
||||||
|
" Equity:Opening-Balances -abc CNY\n"+
|
||||||
|
" Assets:TestAccount abc CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(
|
||||||
|
"2024-09-01 *\n"+
|
||||||
|
" Equity:Opening-Balances -1/0 CNY\n"+
|
||||||
|
" 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) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
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"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(allNewTransactions))
|
||||||
|
|
||||||
|
assert.Equal(t, "foo bar\t#test\n", allNewTransactions[0].Comment)
|
||||||
|
assert.Equal(t, "Hello\nWorld", allNewTransactions[1].Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountTransactionDataFileParseImportedData_InvalidTransaction(t *testing.T) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountTransactionDataFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrNotSupportedSplitTransactions.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeancountTransactionDataFileParseImportedData_MissingTransactionRequiredData(t *testing.T) {
|
||||||
|
importer := BeancountTransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing Transaction Time
|
||||||
|
_, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(
|
||||||
|
"* \"narration\"\n"+
|
||||||
|
" Equity:Opening-Balances -123.45 CNY\n"+
|
||||||
|
" 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 = importer.ParseImportedData(context, user, []byte(
|
||||||
|
"2024-09-01 * \"narration\"\n"+
|
||||||
|
" Equity:Opening-Balances -123.45 CNY\n"+
|
||||||
|
" 123.45 CNY\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
|
||||||
|
// Missing Amount
|
||||||
|
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(
|
||||||
|
"2024-09-01 * \"narration\"\n"+
|
||||||
|
" Equity:Opening-Balances\n"+
|
||||||
|
" Assets:TestAccount\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
|
||||||
|
// Missing Commodity
|
||||||
|
_, _, _, _, _, _, err = importer.ParseImportedData(context, user, []byte(
|
||||||
|
"2024-09-01 * \"narration\"\n"+
|
||||||
|
" Equity:Opening-Balances -123.45\n"+
|
||||||
|
" Assets:TestAccount 123.45\n"), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package beancount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var beancountTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var BEANCOUNT_TRANSACTION_TAG_SEPARATOR = "#"
|
||||||
|
|
||||||
|
// beancountTransactionDataTable defines the structure of Beancount transaction data table
|
||||||
|
type beancountTransactionDataTable struct {
|
||||||
|
allData []*beancountTransactionEntry
|
||||||
|
accountMap map[string]*beancountAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
// beancountTransactionDataRow defines the structure of Beancount transaction data row
|
||||||
|
type beancountTransactionDataRow struct {
|
||||||
|
dataTable *beancountTransactionDataTable
|
||||||
|
data *beancountTransactionEntry
|
||||||
|
finalItems map[datatable.TransactionDataTableColumn]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// beancountTransactionDataRowIterator defines the structure of Beancount transaction data row iterator
|
||||||
|
type beancountTransactionDataRowIterator struct {
|
||||||
|
dataTable *beancountTransactionDataTable
|
||||||
|
currentIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasColumn returns whether the transaction data table has specified column
|
||||||
|
func (t *beancountTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
|
||||||
|
_, exists := beancountTransactionSupportedColumns[column]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionRowCount returns the total count of transaction data row
|
||||||
|
func (t *beancountTransactionDataTable) TransactionRowCount() int {
|
||||||
|
return len(t.allData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionRowIterator returns the iterator of transaction data row
|
||||||
|
func (t *beancountTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
|
||||||
|
return &beancountTransactionDataRowIterator{
|
||||||
|
dataTable: t,
|
||||||
|
currentIndex: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether this row is valid data for importing
|
||||||
|
func (r *beancountTransactionDataRow) IsValid() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetData returns the data in the specified column type
|
||||||
|
func (r *beancountTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
|
||||||
|
_, exists := beancountTransactionSupportedColumns[column]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return r.finalItems[column]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasNext returns whether the iterator does not reach the end
|
||||||
|
func (t *beancountTransactionDataRowIterator) HasNext() bool {
|
||||||
|
return t.currentIndex+1 < len(t.dataTable.allData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the next transaction data row
|
||||||
|
func (t *beancountTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
|
||||||
|
if t.currentIndex+1 >= len(t.dataTable.allData) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.currentIndex++
|
||||||
|
|
||||||
|
data := t.dataTable.allData[t.currentIndex]
|
||||||
|
rowItems, err := t.parseTransaction(ctx, user, data)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &beancountTransactionDataRow{
|
||||||
|
dataTable: t.dataTable,
|
||||||
|
data: data,
|
||||||
|
finalItems: rowItems,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *beancountTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, beancountEntry *beancountTransactionEntry) (map[datatable.TransactionDataTableColumn]string, error) {
|
||||||
|
data := make(map[datatable.TransactionDataTableColumn]string, len(beancountTransactionSupportedColumns))
|
||||||
|
|
||||||
|
if beancountEntry.Date == "" {
|
||||||
|
return nil, errs.ErrMissingTransactionTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beancount supports the international ISO 8601 standard format for dates, with dashes or the same ordering with slashes
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = strings.ReplaceAll(beancountEntry.Date, "/", "-") + " 00:00:00"
|
||||||
|
|
||||||
|
if len(beancountEntry.Postings) == 2 {
|
||||||
|
splitData1 := beancountEntry.Postings[0]
|
||||||
|
splitData2 := beancountEntry.Postings[1]
|
||||||
|
|
||||||
|
account1 := t.dataTable.accountMap[splitData1.Account]
|
||||||
|
account2 := t.dataTable.accountMap[splitData2.Account]
|
||||||
|
|
||||||
|
if account1 == nil || account2 == nil {
|
||||||
|
return nil, errs.ErrMissingAccountData
|
||||||
|
}
|
||||||
|
|
||||||
|
amount1, err := utils.ParseAmount(splitData1.Amount)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse amount \"%s\", because %s", splitData1.Amount, err.Error())
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
amount2, err := utils.ParseAmount(splitData2.Amount)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse amount \"%s\", because %s", splitData2.Amount, err.Error())
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((account1.AccountType == beancountEquityAccountType || account1.AccountType == beancountIncomeAccountType) && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType)) ||
|
||||||
|
((account2.AccountType == beancountEquityAccountType || account2.AccountType == beancountIncomeAccountType) && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType)) { // income
|
||||||
|
fromAccount := account1
|
||||||
|
toAccount := account2
|
||||||
|
toCurrency := splitData2.Commodity
|
||||||
|
toAmount := amount2
|
||||||
|
|
||||||
|
if (account2.AccountType == beancountEquityAccountType || account2.AccountType == beancountIncomeAccountType) && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType) {
|
||||||
|
fromAccount = account2
|
||||||
|
toAccount = account1
|
||||||
|
toCurrency = splitData1.Commodity
|
||||||
|
toAmount = amount1
|
||||||
|
}
|
||||||
|
|
||||||
|
if fromAccount.isOpeningBalanceEquityAccount() {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE))
|
||||||
|
} else {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME))
|
||||||
|
}
|
||||||
|
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = fromAccount.Name
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = toAccount.Name
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = toCurrency
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(toAmount)
|
||||||
|
} else if account1.AccountType == beancountExpensesAccountType && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) ||
|
||||||
|
(account2.AccountType == beancountExpensesAccountType && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType)) { // expense
|
||||||
|
fromAccount := account1
|
||||||
|
fromCurrency := splitData1.Commodity
|
||||||
|
fromAmount := amount1
|
||||||
|
toAccount := account2
|
||||||
|
|
||||||
|
if account1.AccountType == beancountExpensesAccountType && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) {
|
||||||
|
fromAccount = account2
|
||||||
|
fromCurrency = splitData2.Commodity
|
||||||
|
fromAmount = amount2
|
||||||
|
toAccount = account1
|
||||||
|
}
|
||||||
|
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE))
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = toAccount.Name
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromCurrency
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-fromAmount)
|
||||||
|
} else if (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType) &&
|
||||||
|
(account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) {
|
||||||
|
var fromAccount, toAccount *beancountAccount
|
||||||
|
var fromAmount, toAmount int64
|
||||||
|
var fromCurrency, toCurrency string
|
||||||
|
|
||||||
|
if amount1 < 0 {
|
||||||
|
fromAccount = account1
|
||||||
|
fromCurrency = splitData1.Commodity
|
||||||
|
fromAmount = -amount1
|
||||||
|
toAccount = account2
|
||||||
|
toCurrency = splitData2.Commodity
|
||||||
|
toAmount = amount2
|
||||||
|
} else if amount2 < 0 {
|
||||||
|
fromAccount = account2
|
||||||
|
fromCurrency = splitData2.Commodity
|
||||||
|
fromAmount = -amount2
|
||||||
|
toAccount = account1
|
||||||
|
toCurrency = splitData1.Commodity
|
||||||
|
toAmount = amount1
|
||||||
|
} else {
|
||||||
|
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transfer transaction, because unexcepted account amounts \"%d\" and \"%d\"", amount1, amount2)
|
||||||
|
return nil, errs.ErrInvalidBeancountFile
|
||||||
|
}
|
||||||
|
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER))
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromCurrency
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(fromAmount)
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = toAccount.Name
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = toCurrency
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(toAmount)
|
||||||
|
} else {
|
||||||
|
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transaction, because unexcepted account types \"%d\" and \"%d\"", account1.AccountType, account2.AccountType)
|
||||||
|
return nil, errs.ErrThereAreNotSupportedTransactionType
|
||||||
|
}
|
||||||
|
} else if len(beancountEntry.Postings) <= 1 {
|
||||||
|
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transaction, because postings count is %d", len(beancountEntry.Postings))
|
||||||
|
return nil, errs.ErrInvalidBeancountFile
|
||||||
|
} else {
|
||||||
|
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse split transaction, because postings count is %d", len(beancountEntry.Postings))
|
||||||
|
return nil, errs.ErrNotSupportedSplitTransactions
|
||||||
|
}
|
||||||
|
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TAGS] = strings.Join(beancountEntry.Tags, BEANCOUNT_TRANSACTION_TAG_SEPARATOR)
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = beancountEntry.Narration
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNewBeancountTransactionDataTable(beancountData *beancountData) (*beancountTransactionDataTable, error) {
|
||||||
|
if beancountData == nil {
|
||||||
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||||
|
}
|
||||||
|
|
||||||
|
return &beancountTransactionDataTable{
|
||||||
|
allData: beancountData.Transactions,
|
||||||
|
accountMap: beancountData.Accounts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package camt
|
||||||
|
|
||||||
|
import "encoding/xml"
|
||||||
|
|
||||||
|
type camtCreditDebitIndicator string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CAMT_INDICATOR_CREDIT camtCreditDebitIndicator = "CRDT"
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtStatement struct {
|
||||||
|
Account *camtAccount `xml:"Acct"`
|
||||||
|
Entries []*camtEntry `xml:"Ntry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtAccount struct {
|
||||||
|
IBAN string `xml:"Id>IBAN"`
|
||||||
|
OtherIdentification string `xml:"Id>Othr>Id"`
|
||||||
|
Currency string `xml:"Ccy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtEntry struct {
|
||||||
|
Amount *camtAmount `xml:"Amt"`
|
||||||
|
CreditDebitIndicator camtCreditDebitIndicator `xml:"CdtDbtInd"`
|
||||||
|
BookingDate *camtDate `xml:"BookgDt"`
|
||||||
|
EntryDetails *camtEntryDetails `xml:"NtryDtls"`
|
||||||
|
AdditionalEntryInformation string `xml:"AddtlNtryInf"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtAmount struct {
|
||||||
|
Value string `xml:",chardata"`
|
||||||
|
Currency string `xml:"Ccy,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtDate struct {
|
||||||
|
Date string `xml:"Dt"`
|
||||||
|
DateTime string `xml:"DtTm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtEntryDetails struct {
|
||||||
|
TransactionDetails []*camtTransactionDetails `xml:"TxDtls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtTransactionDetails struct {
|
||||||
|
AmountDetails *camtAmountDetails `xml:"AmtDtls"`
|
||||||
|
RemittanceInformation *camtRemittanceInformation `xml:"RmtInf"`
|
||||||
|
AdditionalTransactionInformation string `xml:"AddtlTxInf"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtAmountDetails struct {
|
||||||
|
InstructedAmount *camtAmount `xml:"InstdAmt>Amt"`
|
||||||
|
TransactionAmount *camtAmount `xml:"TxAmt>Amt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type camtRemittanceInformation struct {
|
||||||
|
Unstructured []string `xml:"Ustrd"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package camt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
|
||||||
|
"golang.org/x/net/html/charset"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"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) {
|
||||||
|
file := &camt053File{}
|
||||||
|
|
||||||
|
err := r.xmlDecoder.Decode(&file)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||||
|
|
||||||
|
return &camt053FileReader{
|
||||||
|
xmlDecoder: xmlDecoder,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errs.ErrInvalidXmlFile
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package camt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var camtTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
||||||
|
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// camtStatementTransactionDataTable defines the structure of camt statement transaction data table
|
||||||
|
type camtStatementTransactionDataTable struct {
|
||||||
|
allStatements []*camtStatement
|
||||||
|
}
|
||||||
|
|
||||||
|
// camtStatementTransactionDataRow defines the structure of camt statement transaction data row
|
||||||
|
type camtStatementTransactionDataRow struct {
|
||||||
|
dataTable *camtStatementTransactionDataTable
|
||||||
|
account *camtAccount
|
||||||
|
entry *camtEntry
|
||||||
|
transactionDetails *camtTransactionDetails
|
||||||
|
finalItems map[datatable.TransactionDataTableColumn]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// camtStatementTransactionDataRowIterator defines the structure of camt statement transaction data row iterator
|
||||||
|
type camtStatementTransactionDataRowIterator struct {
|
||||||
|
dataTable *camtStatementTransactionDataTable
|
||||||
|
currentStatementIndex int
|
||||||
|
currentEntryIndex int
|
||||||
|
currentTransactionDetailsIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasColumn returns whether the transaction data table has specified column
|
||||||
|
func (t *camtStatementTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
|
||||||
|
_, exists := camtTransactionSupportedColumns[column]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionRowCount returns the total count of transaction data row
|
||||||
|
func (t *camtStatementTransactionDataTable) TransactionRowCount() int {
|
||||||
|
totalDataRowCount := 0
|
||||||
|
|
||||||
|
for i := 0; i < len(t.allStatements); i++ {
|
||||||
|
statement := t.allStatements[i]
|
||||||
|
|
||||||
|
for j := 0; j < len(statement.Entries); j++ {
|
||||||
|
entry := statement.Entries[j]
|
||||||
|
|
||||||
|
if entry.EntryDetails != nil {
|
||||||
|
totalDataRowCount += len(entry.EntryDetails.TransactionDetails)
|
||||||
|
} else {
|
||||||
|
totalDataRowCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalDataRowCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionRowIterator returns the iterator of transaction data row
|
||||||
|
func (t *camtStatementTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
|
||||||
|
return &camtStatementTransactionDataRowIterator{
|
||||||
|
dataTable: t,
|
||||||
|
currentStatementIndex: 0,
|
||||||
|
currentEntryIndex: 0,
|
||||||
|
currentTransactionDetailsIndex: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether this row is valid data for importing
|
||||||
|
func (r *camtStatementTransactionDataRow) IsValid() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetData returns the data in the specified column type
|
||||||
|
func (r *camtStatementTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
|
||||||
|
_, exists := camtTransactionSupportedColumns[column]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return r.finalItems[column]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasNext returns whether the iterator does not reach the end
|
||||||
|
func (t *camtStatementTransactionDataRowIterator) HasNext() bool {
|
||||||
|
allStatements := t.dataTable.allStatements
|
||||||
|
|
||||||
|
if t.currentStatementIndex >= len(allStatements) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStatement := allStatements[t.currentStatementIndex]
|
||||||
|
|
||||||
|
if t.currentEntryIndex+1 < len(currentStatement.Entries) {
|
||||||
|
return true
|
||||||
|
} else if t.currentEntryIndex < len(currentStatement.Entries) {
|
||||||
|
currencyEntry := currentStatement.Entries[t.currentEntryIndex]
|
||||||
|
|
||||||
|
if currencyEntry.EntryDetails != nil {
|
||||||
|
if t.currentTransactionDetailsIndex+1 < len(currencyEntry.EntryDetails.TransactionDetails) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if t.currentTransactionDetailsIndex < 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := t.currentStatementIndex + 1; i < len(allStatements); i++ {
|
||||||
|
statement := allStatements[i]
|
||||||
|
|
||||||
|
if len(statement.Entries) < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the next transaction data row
|
||||||
|
func (t *camtStatementTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
|
||||||
|
allStatements := t.dataTable.allStatements
|
||||||
|
|
||||||
|
for i := t.currentStatementIndex; i < len(allStatements); i++ {
|
||||||
|
foundNextRow := false
|
||||||
|
statement := allStatements[i]
|
||||||
|
|
||||||
|
for j := t.currentEntryIndex; j < len(statement.Entries); j++ {
|
||||||
|
if statement.Entries[j].EntryDetails != nil {
|
||||||
|
if t.currentTransactionDetailsIndex+1 < len(statement.Entries[j].EntryDetails.TransactionDetails) {
|
||||||
|
t.currentTransactionDetailsIndex++
|
||||||
|
foundNextRow = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if t.currentTransactionDetailsIndex < 0 {
|
||||||
|
t.currentTransactionDetailsIndex++
|
||||||
|
foundNextRow = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.currentEntryIndex++
|
||||||
|
t.currentTransactionDetailsIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundNextRow {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
t.currentStatementIndex++
|
||||||
|
t.currentEntryIndex = 0
|
||||||
|
t.currentTransactionDetailsIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.currentStatementIndex >= len(allStatements) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStatement := allStatements[t.currentStatementIndex]
|
||||||
|
|
||||||
|
if t.currentEntryIndex >= len(currentStatement.Entries) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
account := currentStatement.Account
|
||||||
|
entry := currentStatement.Entries[t.currentEntryIndex]
|
||||||
|
var transactionDetails *camtTransactionDetails
|
||||||
|
|
||||||
|
if entry.EntryDetails != nil {
|
||||||
|
if t.currentTransactionDetailsIndex >= len(entry.EntryDetails.TransactionDetails) {
|
||||||
|
return nil, nil
|
||||||
|
} else {
|
||||||
|
transactionDetails = entry.EntryDetails.TransactionDetails[t.currentTransactionDetailsIndex]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if t.currentTransactionDetailsIndex >= 1 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rowItems, err := t.parseTransaction(ctx, user, account, entry, transactionDetails)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(ctx, "[camt_statement_transaction_data_table.Next] cannot parsing transaction in entry#%d-transaction_detail#%d (statement#%d), because %s", t.currentEntryIndex, t.currentTransactionDetailsIndex, t.currentStatementIndex, err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &camtStatementTransactionDataRow{
|
||||||
|
dataTable: t.dataTable,
|
||||||
|
account: account,
|
||||||
|
entry: entry,
|
||||||
|
transactionDetails: transactionDetails,
|
||||||
|
finalItems: rowItems,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *camtStatementTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, account *camtAccount, entry *camtEntry, transactionDetails *camtTransactionDetails) (map[datatable.TransactionDataTableColumn]string, error) {
|
||||||
|
data := make(map[datatable.TransactionDataTableColumn]string, len(camtTransactionSupportedColumns))
|
||||||
|
|
||||||
|
if account == nil {
|
||||||
|
return nil, errs.ErrMissingAccountData
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.BookingDate != nil && entry.BookingDate.DateTime != "" {
|
||||||
|
dateTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(entry.BookingDate.DateTime)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrTransactionTimeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.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
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrMissingTransactionTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if account.IBAN != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = account.IBAN
|
||||||
|
} else if account.OtherIdentification != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = account.OtherIdentification
|
||||||
|
}
|
||||||
|
|
||||||
|
if transactionDetails != nil && transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.TransactionAmount != nil && transactionDetails.AmountDetails.TransactionAmount.Currency != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = transactionDetails.AmountDetails.TransactionAmount.Currency
|
||||||
|
} else if entry.Amount != nil && entry.Amount.Currency != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = entry.Amount.Currency
|
||||||
|
} else if account.Currency != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = account.Currency
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrAccountCurrencyInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
amountValue := ""
|
||||||
|
|
||||||
|
if entry.EntryDetails != nil && len(entry.EntryDetails.TransactionDetails) > 1 && transactionDetails != nil { // when there are multiple transaction details in one entry, only use the amount in the transaction details
|
||||||
|
if transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.InstructedAmount != nil && transactionDetails.AmountDetails.InstructedAmount.Value != "" {
|
||||||
|
amountValue = transactionDetails.AmountDetails.InstructedAmount.Value
|
||||||
|
} else if transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.TransactionAmount != nil && transactionDetails.AmountDetails.TransactionAmount.Value != "" {
|
||||||
|
amountValue = transactionDetails.AmountDetails.TransactionAmount.Value
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
} else if entry.Amount != nil && entry.Amount.Value != "" {
|
||||||
|
amountValue = entry.Amount.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
if amountValue == "" {
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
amount, err := utils.ParseAmount(amountValue)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(ctx, "[camt_statement_transaction_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", amountValue, err.Error())
|
||||||
|
return nil, errs.ErrAmountInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
||||||
|
|
||||||
|
if entry.CreditDebitIndicator == CAMT_INDICATOR_CREDIT {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME))
|
||||||
|
} else if entry.CreditDebitIndicator == CAMT_INDICATOR_DEBIT {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE))
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrTransactionTypeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if transactionDetails != nil && transactionDetails.AdditionalTransactionInformation != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = transactionDetails.AdditionalTransactionInformation
|
||||||
|
} else if transactionDetails != nil && transactionDetails.RemittanceInformation != nil && len(transactionDetails.RemittanceInformation.Unstructured) > 0 {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = strings.Join(transactionDetails.RemittanceInformation.Unstructured, "\n")
|
||||||
|
} else if entry.AdditionalEntryInformation != "" {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = entry.AdditionalEntryInformation
|
||||||
|
} else {
|
||||||
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNewCamtStatementTransactionDataTable(camtStatements []*camtStatement) (*camtStatementTransactionDataTable, error) {
|
||||||
|
if len(camtStatements) == 0 {
|
||||||
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||||
|
}
|
||||||
|
|
||||||
|
return &camtStatementTransactionDataTable{
|
||||||
|
allStatements: camtStatements,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var camtTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||||
|
models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)),
|
||||||
|
models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)),
|
||||||
|
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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, 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 {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
camt053Data, err := camt053DataReader.read(ctx)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(camtTransactionTypeNameMapping)
|
||||||
|
|
||||||
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
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 TestCamt052TransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
|
||||||
|
importer := Camt052TransactionDataImporter
|
||||||
|
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.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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T01:23:45+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>DBIT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">0.12</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<Othr>
|
||||||
|
<Id>456</Id>
|
||||||
|
</Othr>
|
||||||
|
</Id>
|
||||||
|
<Ccy>USD</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T23:59:59+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="USD">1.23</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), 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_ParseValidTransactionTime(t *testing.T) {
|
||||||
|
importer := Camt053TransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<Dt>2024-09-01</Dt>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-02T03:04:05Z</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 3, len(allNewTransactions))
|
||||||
|
|
||||||
|
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||||
|
assert.Equal(t, int64(1725165296), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
|
||||||
|
assert.Equal(t, int64(1725246245), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime(t *testing.T) {
|
||||||
|
importer := Camt053TransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024T1</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01 12:34:56</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<Dt>2024/09/01</Dt>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmountAndCurrency(t *testing.T) {
|
||||||
|
importer := Camt053TransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="USD">123.45</Amt>
|
||||||
|
<NtryDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<AmtDtls>
|
||||||
|
<TxAmt>
|
||||||
|
<Amt Ccy="USD">100.23</Amt>
|
||||||
|
</TxAmt>
|
||||||
|
</AmtDtls>
|
||||||
|
</TxDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<AmtDtls>
|
||||||
|
<TxAmt>
|
||||||
|
<Amt Ccy="USD">23.22</Amt>
|
||||||
|
</TxAmt>
|
||||||
|
</AmtDtls>
|
||||||
|
</TxDtls>
|
||||||
|
</NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 2, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||||
|
assert.Equal(t, int64(2322), allNewTransactions[0].Amount)
|
||||||
|
assert.Equal(t, "USD", allNewTransactions[1].OriginalSourceAccountCurrency)
|
||||||
|
assert.Equal(t, int64(10023), allNewTransactions[1].Amount)
|
||||||
|
|
||||||
|
allNewTransactions, _, _, _, _, _, err = 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="USD">123.45</Amt>
|
||||||
|
<NtryDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<AmtDtls>
|
||||||
|
<InstdAmt>
|
||||||
|
<Amt Ccy="USD">99.99</Amt>
|
||||||
|
</InstdAmt>
|
||||||
|
<TxAmt>
|
||||||
|
<Amt Ccy="USD">100.23</Amt>
|
||||||
|
</TxAmt>
|
||||||
|
</AmtDtls>
|
||||||
|
</TxDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<AmtDtls>
|
||||||
|
<InstdAmt>
|
||||||
|
<Amt Ccy="USD">23.46</Amt>
|
||||||
|
</InstdAmt>
|
||||||
|
<TxAmt>
|
||||||
|
<Amt Ccy="USD">23.22</Amt>
|
||||||
|
</TxAmt>
|
||||||
|
</AmtDtls>
|
||||||
|
</TxDtls>
|
||||||
|
</NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 2, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||||
|
assert.Equal(t, int64(2346), allNewTransactions[0].Amount)
|
||||||
|
assert.Equal(t, "USD", allNewTransactions[1].OriginalSourceAccountCurrency)
|
||||||
|
assert.Equal(t, int64(9999), allNewTransactions[1].Amount)
|
||||||
|
|
||||||
|
allNewTransactions, _, _, _, _, _, err = 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="USD">123.45</Amt>
|
||||||
|
<NtryDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<AmtDtls>
|
||||||
|
<TxAmt>
|
||||||
|
<Amt Ccy="USD">123.45</Amt>
|
||||||
|
</TxAmt>
|
||||||
|
</AmtDtls>
|
||||||
|
</TxDtls>
|
||||||
|
</NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), 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 = 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt>123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "CNY", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||||
|
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCamt053TransactionDataFileParseImportedData_ParseTransactionInvalidAmountAndCurrency(t *testing.T) {
|
||||||
|
importer := Camt053TransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt>123 45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="USD">123.45</Amt>
|
||||||
|
<NtryDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<AmtDtls>
|
||||||
|
</AmtDtls>
|
||||||
|
</TxDtls>
|
||||||
|
<TxDtls>
|
||||||
|
</TxDtls>
|
||||||
|
</NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="USD">123.45</Amt>
|
||||||
|
<NtryDtls>
|
||||||
|
<TxDtls>
|
||||||
|
</TxDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<AmtDtls>
|
||||||
|
</AmtDtls>
|
||||||
|
</TxDtls>
|
||||||
|
</NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing.T) {
|
||||||
|
importer := Camt053TransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
<AddtlNtryInf>Test Entry</AddtlNtryInf>
|
||||||
|
<NtryDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<AddtlTxInf>Test Transaction</AddtlTxInf>
|
||||||
|
<RmtInf>
|
||||||
|
<Ustrd>Test Line 1</Ustrd>
|
||||||
|
<Ustrd>Test Line 2</Ustrd>
|
||||||
|
</RmtInf>
|
||||||
|
</TxDtls>
|
||||||
|
</NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), 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 = 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
<AddtlNtryInf>Test Entry</AddtlNtryInf>
|
||||||
|
<NtryDtls>
|
||||||
|
<TxDtls>
|
||||||
|
<RmtInf>
|
||||||
|
<Ustrd>Test Line 1</Ustrd>
|
||||||
|
<Ustrd>Test Line 2</Ustrd>
|
||||||
|
</RmtInf>
|
||||||
|
</TxDtls>
|
||||||
|
</NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), 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 = 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
<AddtlNtryInf>Test Entry</AddtlNtryInf>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, "Test Entry", allNewTransactions[0].Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCamt053TransactionDataFileParseImportedData_MissingAccountNode(t *testing.T) {
|
||||||
|
importer := Camt053TransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingAccountData.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredNode(t *testing.T) {
|
||||||
|
importer := Camt053TransactionDataImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrMissingTransactionTime.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<Amt Ccy="CNY">123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
<Ccy>CNY</Ccy>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||||
|
|
||||||
|
_, _, _, _, _, _, 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>
|
||||||
|
<Stmt>
|
||||||
|
<Acct>
|
||||||
|
<Id>
|
||||||
|
<IBAN>123</IBAN>
|
||||||
|
</Id>
|
||||||
|
</Acct>
|
||||||
|
<Ntry>
|
||||||
|
<BookgDt>
|
||||||
|
<DtTm>2024-09-01T12:34:56+08:00</DtTm>
|
||||||
|
</BookgDt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Amt>123.45</Amt>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>`), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user