371 Commits

Author SHA1 Message Date
jacob1123 16b7049438 Add Transaction to list after saving
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-20 23:01:25 +00:00
jacob1123 73e3b49b40 Extract component for new Transaction 2022-02-20 23:00:09 +00:00
jacob1123 953d348bed Handle new Payees 2022-02-20 23:00:07 +00:00
jacob1123 a6eb2a2253 Merge pull request 'Add code linting to build' (#13) from linting into master
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #13
2022-02-20 23:53:47 +01:00
jacob1123 578e7d071c Get session secret from env instead of hardcoding
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-20 22:51:54 +00:00
jacob1123 4688d2d94d Remove commented code and TODOs 2022-02-20 22:46:51 +00:00
jacob1123 cfda327a5d Enable linting nfor CI
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-20 22:41:56 +00:00
jacob1123 1bd38bb367 Move comment
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-20 22:40:58 +00:00
jacob1123 19d2ddbd65 Minor improvements 2022-02-20 22:37:34 +00:00
jacob1123 3cb39d978a Fix multiple linter errors 2022-02-20 22:33:29 +00:00
jacob1123 e08a21b750 Disable linter varnamelen 2022-02-20 22:33:15 +00:00
jacob1123 6929c940c4 Improve ynab import by extracting methods
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-20 21:51:07 +00:00
jacob1123 2423bdd3ee Improvements
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-20 21:33:18 +00:00
jacob1123 22ec0433bf Disable gci 2022-02-20 21:33:05 +00:00
jacob1123 c03d16878a Minimal linting improvements 2022-02-20 21:19:28 +00:00
jacob1123 545f223a97 Comments 2022-02-20 21:06:17 +00:00
jacob1123 260ac2d4ad Run gci 2022-02-20 21:03:55 +00:00
jacob1123 d815e8c3cd Ignore forcetypeassert for token map 2022-02-20 21:00:30 +00:00
jacob1123 91b8cc06b2 imports, comments and formatting 2022-02-20 20:58:48 +00:00
jacob1123 75a6ce1577 Use all gofiles as sources for go build 2022-02-20 20:53:18 +00:00
jacob1123 a0d89ee93a Revert "UNSURE explicitly init NullUUIDs"
This reverts commit 62085cb694.
2022-02-20 20:52:15 +00:00
jacob1123 e39d1dc6e3 Disable exhaustivstruct 2022-02-20 20:52:01 +00:00
jacob1123 1dcd0d2f6d Reformat config.go 2022-02-20 20:51:10 +00:00
jacob1123 62085cb694 UNSURE explicitly init NullUUIDs 2022-02-20 20:46:20 +00:00
jacob1123 ca51ac5e27 Use AbortWithStatusJSON instead of AbortWithError
continuous-integration/drone/pr Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
continuous-integration/drone/push Build is passing
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-20 20:42:57 +00:00
jacob1123 36b2f12183 Renames 2022-02-20 20:42:27 +00:00
jacob1123 96b514ccf8 Fix capitalization of errors 2022-02-20 20:42:16 +00:00
jacob1123 b52ed21d1d Remove cost and just use defaultCost 2022-02-20 20:42:07 +00:00
jacob1123 787165b7f1 Disable ifshort 2022-02-20 20:41:58 +00:00
jacob1123 8035403416 Use camelCase for json identifiers 2022-02-20 16:51:48 +00:00
jacob1123 1a1971246d Use short syntax for errcheck where possible 2022-02-20 16:51:28 +00:00
jacob1123 9b92e2b551 Run tests in parallel 2022-02-20 16:51:17 +00:00
jacob1123 c5be03ab6b Extract error consts 2022-02-20 16:51:01 +00:00
jacob1123 77afe700ae Fix missing value usage
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-19 22:13:55 +00:00
jacob1123 f0961ccc3c Disable linting for prod build while open errors exist but enable for dev instead
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-19 22:06:11 +00:00
jacob1123 1d2ae0e394 Renames and dots in comments
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-19 22:04:51 +00:00
jacob1123 b0175542f1 Return own error 2022-02-19 22:04:35 +00:00
jacob1123 558fddc139 Disable linter testpackage 2022-02-19 22:04:27 +00:00
jacob1123 02ba80a555 Some linting fixes 2022-02-19 21:53:30 +00:00
jacob1123 1a19d3a197 Enable formatting via gofumpt 2022-02-19 21:53:13 +00:00
jacob1123 052a2628ab Disable forbidigo and nlreturn linters 2022-02-19 21:42:19 +00:00
jacob1123 46b9b82f30 Small refactorings 2022-02-19 21:38:57 +00:00
jacob1123 c5a0f49719 Disable wsl linter
Seems to be excessive
2022-02-19 21:38:38 +00:00
jacob1123 649f937254 Remove context from YNABImport struct 2022-02-19 21:28:28 +00:00
jacob1123 0f2501dcbd Rename parameter to other 2022-02-19 21:28:18 +00:00
jacob1123 daadfd45bc Rename http package to server 2022-02-19 21:28:04 +00:00
jacob1123 72b5bdde4f Minor fixes
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-15 12:59:53 +00:00
jacob1123 737d5fb101 Fix wrong executable name 2022-02-15 12:59:41 +00:00
jacob1123 bb4548c50d Improve error messages
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-15 12:41:12 +00:00
jacob1123 7b20bc9822 Disable deprecated linters 2022-02-15 12:38:30 +00:00
jacob1123 2f45c415e0 Move init of StaticFS and rename some vars 2022-02-15 12:37:23 +00:00
jacob1123 74a53954de Wrap more errors 2022-02-15 12:37:04 +00:00
jacob1123 7a0c4a17a2 Ignore gin.Context for varnamelen 2022-02-15 12:36:33 +00:00
jacob1123 71c54c9373 Enable all linters 2022-02-15 12:36:19 +00:00
jacob1123 584e7ef393 Add explicit CI task to Taskfile 2022-02-15 12:27:15 +00:00
jacob1123 aaf16dbe92 Add deps for dev-docker target 2022-02-15 12:26:55 +00:00
jacob1123 d8e0f5a160 Add explicit CI task to Taskfile 2022-02-15 12:26:48 +00:00
jacob1123 38dfa540b4 Fix issues from golangci 2022-02-15 11:52:06 +00:00
jacob1123 835a15ec08 Ad config for golangci
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-15 11:51:38 +00:00
jacob1123 4bbbc0be13 Add vet fmt and linters to build
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/pr/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-15 09:33:17 +00:00
jacob1123 8f6974e151 Add golang-ci to dev image 2022-02-15 09:30:54 +00:00
jacob1123 368ac7f15d Merge pull request 'Use vue's Composition API in components' (#11) from vue-composition into master
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #11
2022-02-15 10:13:47 +01:00
jacob1123 0d20d9bfb8 Use setTitle everywhere
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
continuous-integration/drone/pr Build is passing
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-15 08:35:29 +00:00
jacob1123 4276c51268 Remove unused interface
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-15 08:27:09 +00:00
jacob1123 57930d0e5d Rewrite addCategoriesForMonth with patch call
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-15 08:25:41 +00:00
jacob1123 fe018e1953 Reformat 2022-02-15 08:25:30 +00:00
jacob1123 e7a085273b Fix missing computed calls in Account 2022-02-15 08:25:12 +00:00
jacob1123 5bbd096fc8 Convert other components 2022-02-15 08:20:04 +00:00
jacob1123 452d63c329 Define Transaction interface and use number instead of Number 2022-02-15 08:04:42 +00:00
jacob1123 d28c894d21 Convert NewBudget and TransactionRow 2022-02-15 08:04:25 +00:00
jacob1123 1a79177422 Fix missing computed calls in BudgetSidebar
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-14 22:49:30 +00:00
jacob1123 0aa877d7d4 Merge pull request 'Use vue's Composition API' (#10) from vue-composition into master
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #10
2022-02-14 23:44:00 +01:00
jacob1123 87a70ee5fa Revert
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
continuous-integration/drone/pr Build is passing
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-14 22:39:14 +00:00
jacob1123 0a030eaee1 Convert other pages to composition API 2022-02-14 22:24:42 +00:00
jacob1123 d11c0036b5 Do not use a store for API 2022-02-14 08:12:41 +00:00
jacob1123 ca93e9cd55 Migrate Account.vue to composition API
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-14 08:06:16 +00:00
jacob1123 a061ffd350 Merge pull request 'Implement minor fixes' (#9) from minor-fixes into master
ci/woodpecker/push/woodpecker Pipeline was successful
continuous-integration/drone/push Build is passing
Reviewed-on: #9
2022-02-14 08:48:07 +01:00
jacob1123 5633c029ac Update Earthfile and production docker-compose.yml
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-13 13:20:18 +00:00
jacob1123 a97d050ead Specify Map serializer for budgets
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-13 13:16:50 +00:00
jacob1123 958929fd16 Update docker-compose to use new tag 2022-02-13 13:16:50 +00:00
jacob1123 a61d80ee1f Implement SPA handling in Backend 2022-02-13 13:16:50 +00:00
jacob1123 41c5095b8b Merge pull request 'Use woodpecker for CI' (#8) from woodpecker into master
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #8
2022-02-13 14:16:38 +01:00
jacob1123 c074dfe865 Fix Taskfile
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-12 00:00:33 +00:00
jacob1123 fa8a2854f2 Add node packages to image
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-12 00:00:19 +00:00
jacob1123 15bb73de30 Add secrects
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-11 23:54:26 +00:00
jacob1123 e506510fde Remove user and add go deps from builder image 2022-02-11 23:49:43 +00:00
jacob1123 11ac8758da Disable docker from taskfile
continuous-integration/drone/pr Build is passing
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is passing
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-11 23:43:55 +00:00
jacob1123 3db5e1e72c Reenable docker push
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/pr Build is failing
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-11 23:42:03 +00:00
jacob1123 4e2a783b2e Extract variable 2022-02-11 23:40:46 +00:00
jacob1123 bb83563bc6 Use docker build-target
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:35:15 +00:00
jacob1123 0a21c59eff Fetch deps before build
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:33:47 +00:00
jacob1123 3308b58524 Fix sources in Taskfile
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:30:53 +00:00
jacob1123 941b642f39 Build docker within task
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:16:57 +00:00
jacob1123 6a77c71df4 Always pull dev image
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:59:38 +00:00
jacob1123 bf20914c1c Use default build name
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:58:31 +00:00
jacob1123 7874ef69a2 Fix Taskfile
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:57:26 +00:00
jacob1123 2e719b590e Add admin page 2022-02-11 22:52:00 +00:00
jacob1123 95d8e4fccc Rewrite event filter
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:49:06 +00:00
jacob1123 7cf106eb85 Rename 2022-02-11 22:41:35 +00:00
jacob1123 148fc18cd8 Fix schema 2022-02-11 22:41:07 +00:00
jacob1123 47095ae6ec Add .woodpecker.yaml 2022-02-11 22:39:19 +00:00
jacob1123 200fa1835a Merge pull request 'Replace vuex by pinia' (#7) from pinia into master
Reviewed-on: #7
2022-02-11 23:20:06 +01:00
jacob1123 cd0b370b50 Fix typescript errors 2022-02-11 22:18:00 +00:00
jacob1123 1af69b047d Fix mapping for menu in App.vue 2022-02-11 22:03:37 +00:00
jacob1123 f6cb6d4163 Do not use useStorage for root state 2022-02-11 22:03:26 +00:00
jacob1123 45389e01be Use vueuse useStorage instead of manually using localStorage 2022-02-11 21:38:06 +00:00
jacob1123 5868c3310e Fix Map.values() access and convert to array 2022-02-10 18:07:52 +00:00
jacob1123 1dc818f51f Add pinia-logger 2022-02-10 16:30:53 +00:00
jacob1123 0e342f2bcf Try to move perstisting to stores 2022-02-10 16:30:42 +00:00
jacob1123 21dcd7837b Bugfixes 2022-02-10 16:07:29 +00:00
jacob1123 c693625e34 Remove vuex 2022-02-10 15:49:30 +00:00
jacob1123 8b0e368d58 Fix return type of AuthHeaders 2022-02-10 13:35:51 +00:00
jacob1123 0c094d6f6b And on 2022-02-09 23:16:16 +00:00
jacob1123 9b8ae7a44d And on… 2022-02-09 23:14:48 +00:00
jacob1123 08330ce33c Migrate farther 2022-02-09 23:12:26 +00:00
jacob1123 2d0737a10c First step from vuex to pinia 2022-02-09 23:08:43 +00:00
jacob1123 2137460187 Merge pull request 'Implement first unit-tests' (#6) from unit-tests into master
Reviewed-on: #6
2022-02-09 23:41:08 +01:00
jacob1123 6ab8a96888 Implement first db-test using go-txdb 2022-02-09 22:40:35 +00:00
jacob1123 c3db535e10 go get go-txdb 2022-02-09 22:40:35 +00:00
jacob1123 5d292b2a31 Add first test 2022-02-09 22:40:35 +00:00
jacob1123 e934d407c2 Use simple connection string in config 2022-02-09 22:40:35 +00:00
jacob1123 2c71c521f9 Add bordersr to inputs 2022-02-09 22:40:35 +00:00
jacob1123 3bd5845068 Focus next element on Enter 2022-02-09 22:40:35 +00:00
jacob1123 c3a1564c4e Extract LoadRoutes method 2022-02-09 22:40:35 +00:00
jacob1123 922041856f Extract struct for return value of getTransactionsForAccount 2022-02-09 22:40:35 +00:00
jacob1123 6cafaec422 Inline tv and bv vars 2022-02-09 22:40:35 +00:00
jacob1123 499d78a781 Merge pull request 'design-improvements' (#5) from design-improvements into master
Reviewed-on: #5
2022-02-09 23:40:27 +01:00
jacob1123 227c2a7564 Align amount input right 2022-02-09 22:36:21 +00:00
jacob1123 07cd7a7c2b Add table Header 2022-02-09 22:36:21 +00:00
jacob1123 bd1e1cbfb8 Move Transaction row to own component 2022-02-09 22:36:21 +00:00
jacob1123 09a242f6f6 Merge pull request 'Show transfer account in transaction list' (#4) from show-transfer-account into master
Reviewed-on: #4
2022-02-09 23:35:36 +01:00
jacob1123 4eeecc2bd9 Show transfer account if filled 2022-02-08 21:28:55 +00:00
jacob1123 378d764220 Merge pull request 'Convert frontend to Vue' (#3) from vue into master
Reviewed-on: #3
2022-02-08 22:20:11 +01:00
jacob1123 2559e40ebd Merge branch 'master' into vue 2022-02-08 21:19:47 +00:00
jacob1123 0579ec5106 Save and restore ExpandMenu state 2022-02-08 21:14:44 +00:00
jacob1123 33bdfaddc4 Move budgeting to own route 2022-02-08 21:14:36 +00:00
jacob1123 eb195dfd29 Redirect to current route from site without date 2022-02-08 11:13:55 +00:00
jacob1123 d2b414f328 Always treat Currency.value as number 2022-02-08 10:53:06 +00:00
jacob1123 e873795562 Show sum for all on/off-budget accounts 2022-02-07 22:38:46 +00:00
jacob1123 33c83c0a69 Shrink date input to match column width 2022-02-07 22:19:59 +00:00
jacob1123 2646c5d3b7 Use Currency component for Account header 2022-02-07 22:19:48 +00:00
jacob1123 3ef5836607 Implement simple keyboard selection 2022-02-07 22:19:24 +00:00
jacob1123 b5c657978c Alternate row colors for transactions 2022-02-07 16:39:06 +00:00
jacob1123 0136e3b978 Use currency component for transactions 2022-02-07 16:38:53 +00:00
jacob1123 427e7e5359 Extract value formatting to computed property 2022-02-07 16:38:26 +00:00
jacob1123 139b6ec636 Convert multiple class binding to array syntax 2022-02-07 16:38:08 +00:00
jacob1123 9dad1dabbd Implement new transaction 2022-02-07 16:17:14 +00:00
jacob1123 95fcb9a586 Split Menu between md and smaller devices 2022-02-07 16:07:54 +00:00
jacob1123 3c1d83d8a2 Fix negative values smaller than zero 2022-02-07 16:07:37 +00:00
jacob1123 f09a2a4ca7 Fix multiplication instead of division 2022-02-06 22:21:04 +00:00
jacob1123 487aa89f18 Use Numeric in JSON output 2022-02-06 22:12:48 +00:00
jacob1123 5763409aa8 Split store 2022-02-06 20:15:59 +00:00
jacob1123 5ca09d2825 Fix calling dispatch for mutation 2022-02-06 20:15:29 +00:00
jacob1123 e18e68c964 Comment not implemented routes 2022-02-06 20:15:14 +00:00
jacob1123 065159a817 Cleanup unused backend code and routes 2022-02-06 20:15:00 +00:00
jacob1123 68c2d3ff28 Fix typing issues in Settings 2022-02-05 15:00:26 +00:00
jacob1123 0f2db4985f Remove old plugins 2022-02-05 14:54:14 +00:00
jacob1123 f532ffc0c1 Rename routes.ts 2022-02-05 14:36:20 +00:00
jacob1123 4edd1a8bf1 Cleanup more deps 2022-02-05 14:36:01 +00:00
jacob1123 a7890bce16 Add vue shim 2022-02-05 14:27:20 +00:00
jacob1123 ddaf647d87 Add getters for current budget ID and Name 2022-02-05 14:26:56 +00:00
jacob1123 80b5a7abfe Small layout improvements 2022-02-05 13:48:31 +00:00
jacob1123 2204188600 Actually call API 2022-02-04 21:43:51 +00:00
jacob1123 3d1d1308ac Use JSON params instead of PostForm 2022-02-04 21:43:35 +00:00
jacob1123 d5904a7c4a Implement testing endpoint for new transaction 2022-02-04 21:43:03 +00:00
jacob1123 d9c03e231e Improve error when no auth supplied 2022-02-04 21:40:22 +00:00
jacob1123 dba1e8c276 Change type of modelValue 2022-02-04 21:18:24 +00:00
jacob1123 ebc2286116 Display name if selected 2022-02-04 21:14:02 +00:00
jacob1123 d647650142 Order payees by name 2022-02-04 21:13:40 +00:00
jacob1123 057c576831 Implement autocomplete for categories 2022-02-04 21:13:30 +00:00
jacob1123 056071c6e6 Make autocomplete usable for multiple types 2022-02-04 20:47:50 +00:00
jacob1123 0305aa86c1 Add Card component 2022-02-04 20:44:08 +00:00
jacob1123 d9aed7603e Implement custom autocomplete component 2022-02-04 20:43:57 +00:00
jacob1123 e18ef79839 Implement minimal autocomplete for Payees 2022-02-04 20:24:29 +00:00
jacob1123 b93e44930a Use hamburger menu icon instead of 'Home'-text 2022-02-04 20:01:26 +00:00
jacob1123 c8e9a8b375 Improve login page 2022-02-04 19:59:21 +00:00
jacob1123 1d0fe60ea4 Improve layout 2022-02-04 19:54:32 +00:00
jacob1123 3f7f646120 Extract card and use for budget list 2022-02-04 17:16:03 +00:00
jacob1123 dfa8f369f0 Make it work slightly ;-) 2022-02-04 16:56:17 +00:00
jacob1123 d825379a01 Try to use tailwind 2022-02-04 16:34:23 +00:00
jacob1123 f7dfc7b455 Initialize tailwindcss 2022-02-04 15:53:56 +00:00
jacob1123 2203542d90 Regenerate yarn.lock 2022-02-04 15:53:14 +00:00
jacob1123 72f9a6413f Remove vuetify 2022-02-04 15:51:50 +00:00
jacob1123 bf25922fc4 Add fuzzy module 2022-02-04 15:46:17 +00:00
jacob1123 46d727c650 Try to implement autocomplete for payees 2022-02-01 21:08:37 +00:00
jacob1123 b6628dd8cb Fallback to empty list if budgets or accounts are unset 2022-02-01 20:23:46 +00:00
jacob1123 132ae75755 Add index page 2022-02-01 20:23:32 +00:00
jacob1123 2b885ca189 Implement dummy row for new transaction 2022-02-01 20:23:19 +00:00
jacob1123 37a842b395 Add main.ts 2022-02-01 08:15:19 +00:00
jacob1123 9353d82648 Fix diverse errors 2022-01-31 21:45:19 +00:00
jacob1123 b350fe7d74 Handle some differences between js/ts 2022-01-31 21:37:24 +00:00
jacob1123 7cba471de7 Convert most code to ts 2022-01-31 21:14:13 +00:00
jacob1123 27508af3a7 Convert Register.vue to TS 2022-01-31 20:47:56 +00:00
jacob1123 e0981630ab Enable typescript support 2022-01-31 20:47:45 +00:00
jacob1123 a7178e39c9 Add vue CLI to dev image 2022-01-31 20:40:15 +00:00
jacob1123 29f0c51e35 Extract some types 2022-01-31 20:34:32 +00:00
jacob1123 9c0126b14c Format document 2022-01-31 19:58:06 +00:00
jacob1123 34b9c14419 Redirect to dashboard and remove budget on delete 2022-01-31 16:12:17 +00:00
jacob1123 8e2a62929e Fix ignoring return value 2022-01-31 16:03:56 +00:00
jacob1123 95b1ac5943 Implement Delete Budget 2022-01-31 15:50:13 +00:00
jacob1123 1be3b6930d Install go packages as non-root in Dockerfile 2022-01-31 15:49:49 +00:00
jacob1123 e5fe3d06c4 Remember menu state 2022-01-31 14:43:49 +00:00
jacob1123 136f15badc Move showMenu to store 2022-01-31 14:43:20 +00:00
jacob1123 f091ce8945 Handle LOGIN via action 2022-01-29 23:32:11 +00:00
jacob1123 24370c9d32 Show negative colors in red 2022-01-29 23:29:17 +00:00
jacob1123 e8dbb54086 Merge stores and handle fetching from router 2022-01-29 23:26:57 +00:00
jacob1123 6f7aa28d22 Enable vuex logging 2022-01-29 22:39:13 +00:00
jacob1123 e85cf0ea55 Improve layout of Account view 2022-01-29 22:38:23 +00:00
jacob1123 58683a33fb Improve layout of Account 2022-01-28 10:46:56 +00:00
jacob1123 a45d02f9e0 Redesign app bar 2022-01-28 10:35:29 +00:00
jacob1123 0334b35041 Improve Layout of BudgetSettings 2022-01-28 09:44:30 +00:00
jacob1123 b52c4cb787 Make layout more flexible 2022-01-27 21:41:01 +00:00
jacob1123 0db6d6c67a Organize settings in cards 2022-01-27 21:37:04 +00:00
jacob1123 0b1f673c05 Extract action for API calls 2022-01-26 21:55:36 +00:00
jacob1123 0e438e0244 Implement clear 2022-01-26 21:38:41 +00:00
jacob1123 430c4d52da Formatting & remove redundant headline 2022-01-26 21:33:19 +00:00
jacob1123 d5a414266b Split build into dev and prod 2022-01-26 21:33:05 +00:00
jacob1123 47cbaf9660 Implement YNAB Import in Vue 2022-01-26 21:12:42 +00:00
jacob1123 7776ba90f0 Add interactive build to dev container 2022-01-26 20:47:08 +00:00
jacob1123 80c00171c8 Add dev docker-compose 2022-01-26 20:46:22 +00:00
jacob1123 9cc06bbe93 Add plain run command without docker and rename old run to rundocker 2022-01-26 20:45:46 +00:00
jacob1123 829881bd2a Fix missing exeExt in sources 2022-01-26 20:45:26 +00:00
jacob1123 0ad97ce4ff Remove unused HTML endpoints 2022-01-26 20:22:39 +00:00
jacob1123 1aa6d8d58f Add build script to dev container 2022-01-26 20:22:23 +00:00
jacob1123 db8553995c Add simple data stucture to README 2022-01-25 22:31:47 +00:00
jacob1123 de6054359a Remove dashboard call and fetch Budgets with login 2022-01-25 22:29:35 +00:00
jacob1123 9e3dde8076 Rename module 2022-01-25 20:58:32 +00:00
jacob1123 d6165c6ede Add Settings.vue 2022-01-25 20:56:57 +00:00
jacob1123 404bd6625f Add mutation-types.js 2022-01-25 20:56:43 +00:00
jacob1123 463a52fb19 Merge modules 2022-01-25 20:56:32 +00:00
jacob1123 ffed94f586 Implement dummy Budget-Settings and extract setTitle mutation 2022-01-25 20:40:00 +00:00
jacob1123 74c4c7cb02 Improve login/logout
Extract mutation types to mutation-types.js
2022-01-25 20:30:47 +00:00
jacob1123 6dcf7da861 Implement watch for accounts as well 2022-01-25 20:06:26 +00:00
jacob1123 4f2b3b7b22 Display selected budget and update sidebar on change 2022-01-25 19:25:11 +00:00
jacob1123 3b130b8621 Fix amount display and hide account 2022-01-25 19:20:54 +00:00
jacob1123 a4f94cd9d9 Remove old logging 2022-01-25 19:18:19 +00:00
jacob1123 c2bbaebfd2 Implement API endpoint for transactions 2022-01-25 19:18:08 +00:00
jacob1123 458f4f0e8f Implement account-view 2022-01-25 19:03:58 +00:00
jacob1123 e184ef933f Add container to wrap budget list 2022-01-25 12:54:33 +00:00
jacob1123 a2cc19d310 Use props for budgetid 2022-01-25 12:54:25 +00:00
jacob1123 626e0ada40 Add toggle menu button 2022-01-25 12:54:06 +00:00
jacob1123 339c230756 fetchBudget whenever current budget changes 2022-01-25 08:55:54 +00:00
jacob1123 59ae9f978a Rename Budget to BudgetSidebar 2022-01-25 08:55:39 +00:00
jacob1123 af3252277c Implement currentBudget and move infos to sidebar 2022-01-25 08:37:38 +00:00
jacob1123 33990bdccf Exclude node_modules and vendor in VSCode 2022-01-24 19:24:12 +00:00
jacob1123 5fb7542176 Add new budget dialog 2022-01-24 19:23:49 +00:00
jacob1123 c2bcd815d5 API returns Budget directly 2022-01-24 19:22:53 +00:00
jacob1123 be821bc90a Remove unneeded assignment 2022-01-24 19:22:38 +00:00
jacob1123 746680a4d7 Add empty styles file 2022-01-24 16:00:55 +00:00
jacob1123 fa4c9bcaba Add vuetify and webfontloader plugins 2022-01-24 16:00:39 +00:00
jacob1123 3de0447eba Implement register and login using vuetify 2022-01-24 16:00:01 +00:00
jacob1123 d59b9bdbec Add vuetify 2022-01-24 14:47:47 +00:00
jacob1123 93b1712fb5 Add Dockerfile for dev environment 2022-01-24 14:45:05 +00:00
jacob1123 b2b934cfb3 Add logout action 2022-01-23 22:38:38 +00:00
jacob1123 1d39fc7976 Remove old code for accounts 2022-01-23 22:38:25 +00:00
jacob1123 dd160cab17 Convert logout link to method 2022-01-23 22:38:12 +00:00
jacob1123 0e3ece9830 Add Getter for on/offbudget accounts and remove from backend 2022-01-23 22:33:36 +00:00
jacob1123 27298a9860 Add budget module to store 2022-01-23 22:24:40 +00:00
jacob1123 e26bb257cc Add dummy-logout link 2022-01-23 22:24:28 +00:00
jacob1123 0bd63636bb Implement Budget-List via Getter 2022-01-23 22:24:17 +00:00
jacob1123 6086447126 Implement budget display 2022-01-23 22:24:02 +00:00
jacob1123 aae8bbb44e Return JSON and move /budget/:budgetid to API endpoint 2022-01-23 22:23:30 +00:00
jacob1123 f33f0880c4 Move store to own folder 2022-01-23 21:54:46 +00:00
jacob1123 c069a33890 Remove logging after api request 2022-01-23 21:37:26 +00:00
jacob1123 8b4ebadec2 Only save partial state 2022-01-23 21:36:46 +00:00
jacob1123 2b556222ed Fix display of menu-items by login state 2022-01-23 21:35:43 +00:00
jacob1123 3da2e0f2f8 Remove authentication Cookies from Backend 2022-01-23 21:35:23 +00:00
jacob1123 4f72751ed6 Extract variable 2022-01-23 21:15:33 +00:00
jacob1123 c2326060b3 Rename index.js to routes.js 2022-01-22 16:23:10 +00:00
jacob1123 dad3a149d8 Use router-link to budget page 2022-01-22 16:21:53 +00:00
jacob1123 1bdd66a45a Remove HelloWorld and logging, add Budget page 2022-01-22 16:21:53 +00:00
jacob1123 b0ba63118e Add login page 2022-01-22 16:11:42 +00:00
jacob1123 18d11358b2 Remove logo 2022-01-22 16:11:34 +00:00
jacob1123 4af82805ff Save store to localStorage 2022-01-22 16:10:46 +00:00
jacob1123 15ab8a3dac Use Header instead of Cookie 2022-01-21 15:07:15 +00:00
jacob1123 99cf27b649 Return forbidden status in API 2022-01-21 15:07:01 +00:00
jacob1123 bc78cab715 Add proxy to backend to vite config 2022-01-21 14:20:58 +00:00
jacob1123 ddab5998bc Move dashboard to api 2022-01-21 14:20:45 +00:00
jacob1123 929db00a47 Get Dashboards on mounted
continuous-integration/drone/push Build is failing
2022-01-19 21:32:02 +00:00
jacob1123 8a6322bc7e Fix binding href 2022-01-19 21:32:02 +00:00
jacob1123 5601c965dc Fix script exporting computed 2022-01-19 21:32:02 +00:00
jacob1123 2fba4381df Allow access from other hosts 2022-01-19 21:32:02 +00:00
jacob1123 bdf942a17b Import esm-bundler 2022-01-19 21:32:02 +00:00
jacob1123 d354a680f9 Set version correctly 2022-01-19 21:32:02 +00:00
jacob1123 6899e4c88e Try to add store 2022-01-19 20:53:19 +00:00
jacob1123 e0f822dbcc Add vuex 2022-01-18 13:30:47 +00:00
jacob1123 d0e52ddfa6 Implement basic routing 2022-01-18 13:29:51 +00:00
jacob1123 2112192670 Add frontend to build script 2022-01-18 13:29:24 +00:00
jacob1123 13063b47ba Merge pull request 'Add row to create new transaction' (#2) from new-transaction-row into master
continuous-integration/drone/push Build is passing
Reviewed-on: #2
2022-01-18 11:43:20 +01:00
jacob1123 3cda536854 Add vue-router 2022-01-17 22:17:29 +00:00
jacob1123 6d49a549a0 Remove templating completely 2022-01-17 22:17:23 +00:00
jacob1123 aa33c148cb Remove templates and add vue template 2022-01-17 22:05:43 +00:00
jacob1123 663f247080 Import and display Status
continuous-integration/drone/push Build is passing
2022-01-16 15:43:05 +00:00
jacob1123 e094c2a740 Merge branch 'master' into new-transaction-row
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-01-10 11:18:56 +01:00
jacob1123 7fba9e7e4c Fix .drone.yml
continuous-integration/drone/push Build is passing
2022-01-10 10:18:42 +00:00
jacob1123 cc58c0d012 Add row to create new transaction 2022-01-10 10:15:55 +00:00
jacob1123 e138c264ea Merge pull request 'Import transfers as actual Transfers' (#1) from handle-transfers into master
Reviewed-on: #1
2022-01-10 11:15:05 +01:00
jacob1123 a8edeaafa1 Disable docker build for prs 2022-01-10 10:14:10 +00:00
jacob1123 6fe30231d8 Save all unmatched transfers as regular transactions
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2022-01-10 10:10:56 +00:00
jacob1123 49af9cd2ef Fix logging wrong objects 2022-01-10 10:10:56 +00:00
jacob1123 ac27dc783e Also fetch GroupID and highlight groups in transactions-view 2022-01-10 10:10:56 +00:00
jacob1123 3c17d674f9 Fix migration 2022-01-10 10:10:56 +00:00
jacob1123 92725c0f26 Reword clear actions description
continuous-integration/drone/push Build is passing
2022-01-10 10:10:47 +00:00
jacob1123 2ec9c923df Implement matching 2022-01-10 10:10:02 +00:00
jacob1123 beff7afcf7 Add group_id 2022-01-10 10:10:02 +00:00
jacob1123 337d588c3c Include all accounts
continuous-integration/drone/push Build is passing
Before this change, accounts without transactions in the specified timespan had not been included
2022-01-10 10:09:34 +00:00
jacob1123 951e827d20 Add transfer_id 2022-01-09 20:47:43 +00:00
jacob1123 2f3e4bc748 Add transfers to list and skip 2022-01-09 20:27:51 +00:00
jacob1123 d71eb17092 Remove new transaction from transaction edit dialog
continuous-integration/drone/push Build is passing
2021-12-28 20:41:18 +00:00
jacob1123 53dd31fa35 Extract util.go 2021-12-28 20:41:06 +00:00
jacob1123 1a4267186a Add ability to edit payees 2021-12-28 20:40:53 +00:00
jacob1123 5018e5b973 Give sensible name to caching method
continuous-integration/drone/push Build is passing
2021-12-28 20:16:44 +00:00
jacob1123 ed9e75d57a Enable drone CI
continuous-integration/drone/push Build is passing
2021-12-28 20:08:19 +00:00
jacob1123 ed361324dd Fix indentation 2021-12-28 16:14:48 +00:00
jacob1123 6bac09a38e Implement update and delete for transactions 2021-12-27 23:38:30 +00:00
jacob1123 ab43387f06 Use wrapper to prevent password change popups from screwing up the layout 2021-12-27 23:14:50 +00:00
jacob1123 c112d95a41 Add delete button 2021-12-27 23:07:16 +00:00
jacob1123 6fdc0e3b1d Try to fix indentation 2021-12-27 23:07:07 +00:00
jacob1123 f08784ffa7 Fix typo 2021-12-27 23:06:59 +00:00
jacob1123 8188184ac9 Add tag for self-hosted registry 2021-12-27 23:06:38 +00:00
jacob1123 81b3bf334a Add transaction detail view 2021-12-14 15:02:51 +00:00
jacob1123 d0ad0dcb3a Implement categories for new transactions 2021-12-14 14:41:10 +00:00
jacob1123 1ab1fa74e0 Improve new-transaction form by adding account_id and default date 2021-12-14 14:15:08 +00:00
jacob1123 33c54c9f4c Make import available via UI 2021-12-14 14:14:35 +00:00
jacob1123 1ed9344586 Add home button to sidebar 2021-12-14 14:14:12 +00:00
jacob1123 a8bd03a805 Handle circular required keys
Use a dummy-value at first and update it later.
Deferrable doesn't seem to work for NOT NULL - only
for FOREIGN KEYs.
2021-12-14 14:13:23 +00:00
jacob1123 9e01be699a Fix modals not opening 2021-12-14 14:12:01 +00:00
jacob1123 84ddb36d62 Fix reset only undoing one version 2021-12-14 14:11:11 +00:00
jacob1123 8b6a8c3697 Add income_category_id only after categories table exists 2021-12-11 22:08:08 +00:00
jacob1123 208ffce968 Wrap errors 2021-12-11 22:07:53 +00:00
jacob1123 bfba5f4028 Also rebuild on schema change
Files in postgres/schema/ are embedded in an embed.FS
2021-12-11 22:07:34 +00:00
jacob1123 1f2d81f173 Add task build command 2021-12-11 22:07:09 +00:00
jacob1123 c3a93377d9 Fix schema 2021-12-11 21:55:33 +00:00
jacob1123 40a299141d Implement new budget with transaction to be able to satisfy not null columns 2021-12-11 20:19:52 +00:00
jacob1123 935499e3a8 Improve UI
Highlight future transactions
clarify settings are for budget
2021-12-11 20:19:28 +00:00
jacob1123 915964fa4e Add now to funcs available from templates 2021-12-11 20:18:27 +00:00
jacob1123 e9adc763b2 Remove Repository and use Database instead 2021-12-11 20:18:09 +00:00
jacob1123 d5ebf5a5cf Group hidden categories 2021-12-11 15:10:51 +00:00
jacob1123 466775817f Merge added and assigned into moneyUsed 2021-12-11 13:03:26 +00:00
jacob1123 e2413290b4 Extract another method 2021-12-11 13:01:35 +00:00
jacob1123 18cd29cca2 Exctract getDate 2021-12-11 12:52:52 +00:00
jacob1123 caf0126b86 Implement budgeting views by calculating most values locally 2021-12-11 12:47:41 +00:00
jacob1123 6da1b26a2f update go.mod 2021-12-11 12:47:07 +00:00
jacob1123 13993b6b5a Try to calculate balances locally 2021-12-10 18:56:56 +00:00
jacob1123 625e0635fd Fix indentation 2021-12-10 17:09:43 +00:00
jacob1123 1826274ccc Show budget name in sidebar 2021-12-10 16:40:02 +00:00
jacob1123 defbbd1884 Improve documentation for YNAB Import 2021-12-10 16:39:56 +00:00
jacob1123 8116238d48 Add settings page linking to clean and clear 2021-12-10 16:39:44 +00:00
jacob1123 e0eeaadc60 Use same template for account and all-accounts 2021-12-10 16:38:10 +00:00
jacob1123 4cd81592e4 Also watch for templates changes 2021-12-10 16:37:11 +00:00
jacob1123 5d9693838f Complete Taskfile
- Use checksums for go.mod/go.sum
- Disable CGO to be able to use 'FROM SCRATCH' Dockerfile
- Add run command that updates docker-compose
2021-12-10 09:43:59 +00:00
jacob1123 3bec0857d5 Add Taskfile 2021-12-10 09:36:16 +00:00
jacob1123 5e18d51b5d Only show last month's overflow 2021-12-08 15:21:10 +00:00
jacob1123 11179a1593 Also load available last month 2021-12-08 15:15:49 +00:00
jacob1123 7cb7527704 Use date_trunc instead of splitting date into year and month 2021-12-08 15:15:35 +00:00
jacob1123 c3a022b595 Add views with results grouped by month 2021-12-08 14:40:11 +00:00
jacob1123 a0ebdd01aa Implement cleaning to set all historic negative balances to zero 2021-12-07 21:59:06 +00:00
jacob1123 edd1319222 Show available balance including activities of this month 2021-12-07 21:32:36 +00:00
jacob1123 a19d3d6932 Try to enable caching 2021-12-07 21:32:20 +00:00
jacob1123 f4ddf12214 Split displayed accounts by on- or off-budget 2021-12-07 21:20:35 +00:00
jacob1123 04fd687324 Handle null values in numeric 2021-12-07 21:20:35 +00:00
jacob1123 cbda69e827 Handle on_budget in available balance 2021-12-07 21:20:35 +00:00
jacob1123 e3f3dc6748 Add on_budget column to accounts 2021-12-07 21:20:35 +00:00
jacob1123 915379f5cb Make available balance date-dependent 2021-12-07 21:20:35 +00:00
jacob1123 284685fb52 Update Bootstrap 2021-12-07 21:20:35 +00:00
jacob1123 5f4c5d9d51 Display zero values in grey 2021-12-07 20:35:49 +00:00
jacob1123 8c9c78a789 Fix between call being inclusive 2021-12-07 20:28:48 +00:00
jacob1123 64822912d9 Add available balance 2021-12-07 20:22:40 +00:00
jacob1123 1d4bc158a8 Improve handling of context 2021-12-07 19:08:53 +00:00
jacob1123 fbd283cd1c Show date in without time 2021-12-07 15:42:39 +00:00
jacob1123 0ee3f269b5 Split routes into own files 2021-12-07 15:42:29 +00:00
159 changed files with 12689 additions and 13400 deletions
+2 -1
View File
@@ -7,4 +7,5 @@ config.example.json
.gitignore
.vscode/
budgeteer
budgeteer.exe
budgeteer.exe
**/node_modules/
+33
View File
@@ -0,0 +1,33 @@
---
kind: pipeline
type: docker
name: budgeteer
steps:
- name: Taskfile.dev
image: hub.javil.eu/budgeteer:dev
pull: true
commands:
- task ci
- name: docker
image: plugins/docker
settings:
registry: hub.javil.eu
username:
from_secret: docker_user
password:
from_secret: docker_password
repo: hub.javil.eu/budgeteer
context: build
dockerfile: build/Dockerfile
tags:
- latest
when:
event:
exclude:
- pull_request
image_pull_secrets:
- hub.javil.eu
+7 -1
View File
@@ -4,6 +4,7 @@
# Unignore all with extensions
!*.*
!Dockerfile
# Unignore all dirs
!*/
@@ -85,4 +86,9 @@ GitHub.sublime-settings
# Support for Project snippet scope
!.vscode/*.code-snippets
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,sublimetext,go
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,sublimetext,go
node_modules
.DS_Store
dist
dist-ssr
*.local
+26
View File
@@ -0,0 +1,26 @@
linters:
enable-all: true
disable:
- golint
- scopelint
- maligned
- interfacer
- wsl
- forbidigo
- nlreturn
- testpackage
- ifshort
- exhaustivestruct
- gci # not working, shows errors on freshly formatted file
- varnamelen
linters-settings:
errcheck:
exclude-functions:
- io/ioutil.ReadFile
- io.Copy(*bytes.Buffer)
- (*github.com/gin-gonic/gin.Context).AbortWithError
- (*github.com/gin-gonic/gin.Context).AbortWithError
- io.Copy(os.Stdout)
varnamelen:
ignore-decls:
- c *gin.Context
+9
View File
@@ -0,0 +1,9 @@
{
"files.exclude": {
"**/node_modules": true,
"**/vendor": true
},
"gopls": {
"formatting.gofumpt": true,
}
}
+9 -2
View File
@@ -4,14 +4,21 @@
"version": "2.0.0",
"tasks": [
{
"label": "earthly +run",
"label": "task watch +run",
"type": "shell",
"command": "earthly +run",
"command": "task -w run",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "earthly +run",
"type": "shell",
"command": "earthly +run",
"problemMatcher": [],
"group": "build"
}
]
}
+23
View File
@@ -0,0 +1,23 @@
pipeline:
build:
name: Taskfile.dev
image: hub.javil.eu/budgeteer:dev
pull: true
commands:
- task ci
docker:
image: plugins/docker
secrets: [ docker_username, docker_password ]
settings:
registry: hub.javil.eu
repo: hub.javil.eu/budgeteer
context: build
dockerfile: build/Dockerfile
tags:
- latest
when:
event: [push, tag, deployment]
image_pull_secrets:
- hub.javil.eu
+1 -1
View File
@@ -12,7 +12,7 @@ docker:
WORKDIR /app
COPY +build/budgeteer .
ENTRYPOINT ["/app/budgeteer"]
SAVE IMAGE budgeteer:latest
SAVE IMAGE hub.javil.eu/budgeteer:latest
run:
LOCALLY
+18 -1
View File
@@ -1,3 +1,20 @@
# Budgeteer
Budgeting Web-Application
Budgeting Web-Application
## Data structure
1 User
N Budgets
AccountID[]
CategoryID[]
PayeeID[]
N Accounts
TransactionID[]
N Categories
AssignmentID[]
N Payees
N Transactions
N Assignments
+110
View File
@@ -0,0 +1,110 @@
version: '3'
vars:
IMAGE_NAME: hub.javil.eu/budgeteer
tasks:
default:
cmds:
- task: build-prod
sqlc:
desc: sqlc code generation
sources:
- ./sqlc.yaml
- ./postgres/schema/*
- ./postgres/queries/*
generates:
- ./postgres/*.sql.go
cmds:
- sqlc generate
gomod:
desc: Go modules
sources:
- ./go.mod
- ./go.sum
method: checksum
cmds:
- go mod download
build:
desc: Build budgeteer
sources:
- ./go.mod
- ./go.sum
- ./**/*.go
- ./web/dist/**/*
- ./postgres/schema/*
generates:
- build/budgeteer{{exeExt}}
env:
CGO_ENABLED: '0'
cmds:
- go build -o ./build/budgeteer{{exeExt}} ./cmd/budgeteer
build-dev:
desc: Build budgeteer in dev mode
deps: [gomod, sqlc]
cmds:
- go vet
- go fmt
- golangci-lint run
- task: build
build-prod:
desc: Build budgeteer in prod mode
deps: [gomod, sqlc, frontend]
cmds:
- go vet
- go fmt
- golangci-lint run
- task: build
ci:
desc: Run CI build
cmds:
- task: build-prod
frontend:
desc: Build vue frontend
dir: web
sources:
- web/src/**/*
generates:
- web/dist/**/*
cmds:
- yarn
- yarn build
docker:
desc: Build budgeeter:latest
deps: [build-prod]
sources:
- ./build/budgeteer{{exeExt}}
- ./build/Dockerfile
cmds:
- docker build -t {{.IMAGE_NAME}}:latest ./build
- docker push {{.IMAGE_NAME}}:latest
dev-docker:
desc: Build budgeeter:dev
sources:
- ./docker/Dockerfile
- ./docker/build.sh
- ./web/package.json
cmds:
- docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile
- docker push {{.IMAGE_NAME}}:dev
run:
desc: Start budgeteer
deps: [build-dev]
cmds:
- ./build/budgeteer{{exeExt}}
rundocker:
desc: Start docker-compose
deps: [docker]
cmds:
- docker-compose up -d
+30 -23
View File
@@ -1,23 +1,30 @@
package bcrypt
import "golang.org/x/crypto/bcrypt"
// Verifier verifys passwords using Bcrypt
type Verifier struct {
cost int
}
// Verify verifys a Password
func (bv *Verifier) Verify(password string, hashOnDb string) error {
return bcrypt.CompareHashAndPassword([]byte(hashOnDb), []byte(password))
}
// Hash calculates a hash to be stored on the database
func (bv *Verifier) Hash(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bv.cost)
if err != nil {
return "", err
}
return string(hash[:]), nil
}
package bcrypt
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
// Verifier verifys passwords using Bcrypt.
type Verifier struct{}
// Verify verifys a Password.
func (bv *Verifier) Verify(password string, hashOnDB string) error {
err := bcrypt.CompareHashAndPassword([]byte(hashOnDB), []byte(password))
if err != nil {
return fmt.Errorf("verify password: %w", err)
}
return nil
}
// Hash calculates a hash to be stored on the database.
func (bv *Verifier) Hash(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(hash), nil
}
+3
View File
@@ -0,0 +1,3 @@
FROM scratch
COPY ./budgeteer /app/budgeteer
ENTRYPOINT ["/app/budgeteer"]
+17 -14
View File
@@ -1,39 +1,42 @@
package main
import (
"io/fs"
"log"
"net/http"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/config"
"git.javil.eu/jacob1123/budgeteer/http"
"git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/server"
"git.javil.eu/jacob1123/budgeteer/web"
)
func main() {
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Could not load Config: %v", err)
log.Fatalf("Could not load config: %v", err)
}
bv := &bcrypt.Verifier{}
q, db, err := postgres.Connect(cfg.DatabaseHost, cfg.DatabaseUser, cfg.DatabasePassword, cfg.DatabaseName)
queries, err := postgres.Connect("pgx", cfg.DatabaseConnection)
if err != nil {
log.Fatalf("Failed connecting to DB: %v", err)
}
us, err := postgres.NewRepository(q, db)
static, err := fs.Sub(web.Static, "dist")
if err != nil {
log.Fatalf("Failed building Repository: %v", err)
panic("couldn't open static files")
}
tv := &jwt.TokenVerifier{}
h := &http.Handler{
Service: us,
TokenVerifier: tv,
CredentialsVerifier: bv,
handler := &server.Handler{
Service: queries,
TokenVerifier: &jwt.TokenVerifier{
Secret: cfg.SessionSecret,
},
CredentialsVerifier: &bcrypt.Verifier{},
StaticFS: http.FS(static),
}
h.Serve()
handler.Serve()
}
+21 -29
View File
@@ -1,29 +1,21 @@
package config
import (
"os"
)
// Config contains all needed configurations
type Config struct {
DatabaseUser string
DatabaseHost string
DatabasePassword string
DatabaseName string
}
// LoadConfig from path
func LoadConfig() (*Config, error) {
configuration := Config{
DatabaseUser: os.Getenv("BUDGETEER_DB_USER"),
DatabaseHost: os.Getenv("BUDGETEER_DB_HOST"),
DatabasePassword: os.Getenv("BUDGETEER_DB_PASS"),
DatabaseName: os.Getenv("BUDGETEER_DB_NAME"),
}
if configuration.DatabaseName == "" {
configuration.DatabaseName = "budgeteer"
}
return &configuration, nil
}
package config
import (
"os"
)
// Config contains all needed configurations.
type Config struct {
DatabaseConnection string
SessionSecret string
}
// LoadConfig from path.
func LoadConfig() (*Config, error) {
configuration := Config{
DatabaseConnection: os.Getenv("BUDGETEER_DB"),
SessionSecret: os.Getenv("BUDGETEER_SESSION_SECRET"),
}
return &configuration, nil
}
+43
View File
@@ -0,0 +1,43 @@
version: '3.7'
services:
app:
image: hub.javil.eu/budgeteer:dev
container_name: budgeteer
stdin_open: true # docker run -i
tty: true # docker run -t
ports:
- 1323:1323
- 3000:3000
user: '1000'
volumes:
- ~/budgeteer:/src
- ~/.gitconfig:/.gitconfig
- ~/.go:/go
- ~/.cache:/.cache
environment:
BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer
BUDGETEER_SESSION_SECRET: random string for JWT authorization
depends_on:
- db
db:
image: postgres:14
ports:
- 5432:5432
volumes:
- db:/var/lib/postgresql/data
environment:
POSTGRES_USER: budgeteer
POSTGRES_PASSWORD: budgeteer
POSTGRES_DBE: budgeteer
adminer:
image: adminer
ports:
- 1424:8080
depends_on:
- db
volumes:
db:
+1 -1
View File
@@ -2,7 +2,7 @@ version: '3.7'
services:
app:
image: budgeteer:latest
image: hub.javil.eu/budgeteer:latest
container_name: budgeteer
ports:
- 1323:1323
+17
View File
@@ -0,0 +1,17 @@
FROM alpine as godeps
RUN apk add go
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
RUN go install github.com/go-task/task/v3/cmd/task@latest
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
FROM alpine
RUN apk add go
RUN apk add nodejs yarn bash curl git git-perl tmux
ADD docker/build.sh /
RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}"
WORKDIR /src
ADD web/package.json /src/web/
RUN yarn
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
CMD /build.sh
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
tmux new-session -d -s watch 'cd web; yarn dev'
tmux split-window;
tmux send 'task -w run' ENTER;
tmux split-window;
tmux a;
+3 -2
View File
@@ -5,13 +5,14 @@ go 1.17
require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/gin-gonic/gin v1.7.4
github.com/gofrs/uuid v4.0.0+incompatible
github.com/google/uuid v1.3.0
github.com/jackc/pgx/v4 v4.13.0
github.com/pressly/goose/v3 v3.3.1
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa
)
require github.com/DATA-DOG/go-txdb v0.1.5 // indirect
require (
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.13.0 // indirect
@@ -24,7 +25,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.1.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.8.1 // indirect
github.com/jackc/pgtype v1.8.1 // direct
github.com/json-iterator/go v1.1.9 // indirect
github.com/leodido/go-urn v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
+2
View File
@@ -4,6 +4,8 @@ github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/ClickHouse/clickhouse-go v1.5.1/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
github.com/DATA-DOG/go-txdb v0.1.5 h1:kKzz+LYk9qw1+fMyo8/9yDQiNXrJ2HbfX/TY61HkkB4=
github.com/DATA-DOG/go-txdb v0.1.5/go.mod h1:DhAhxMXZpUJVGnT+p9IbzJoRKvlArO2pkHjnGX7o0n0=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
-57
View File
@@ -1,57 +0,0 @@
package http
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AccountsData struct {
AlwaysNeededData
}
func (h *Handler) accounts(c *gin.Context) {
d := AccountsData{
c.MustGet("data").(AlwaysNeededData),
}
c.HTML(http.StatusOK, "accounts.html", d)
}
type AccountData struct {
AlwaysNeededData
Account *postgres.Account
Transactions []postgres.GetTransactionsForAccountRow
}
func (h *Handler) account(c *gin.Context) {
accountID := c.Param("accountid")
accountUUID, err := uuid.Parse(accountID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
account, err := h.Service.DB.GetAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
transactions, err := h.Service.DB.GetTransactionsForAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
d := AccountData{
c.MustGet("data").(AlwaysNeededData),
&account,
transactions,
}
c.HTML(http.StatusOK, "account.html", d)
}
-31
View File
@@ -1,31 +0,0 @@
package http
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pressly/goose/v3"
)
type AdminData struct {
}
func (h *Handler) admin(c *gin.Context) {
d := AdminData{}
c.HTML(http.StatusOK, "admin.html", d)
}
func (h *Handler) clearDatabase(c *gin.Context) {
d := AdminData{}
if err := goose.Down(h.Service.LegacyDB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
if err := goose.Up(h.Service.LegacyDB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
c.HTML(http.StatusOK, "admin.html", d)
}
-45
View File
@@ -1,45 +0,0 @@
package http
import (
"context"
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AlwaysNeededData struct {
Budget postgres.Budget
Accounts []postgres.GetAccountsWithBalanceRow
}
func (h *Handler) getImportantData(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
budget, err := h.Service.DB.GetBudget(context.Background(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
accounts, err := h.Service.DB.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
base := AlwaysNeededData{
Accounts: accounts,
Budget: budget,
}
c.Set("data", base)
c.Next()
}
-37
View File
@@ -1,37 +0,0 @@
package http
import (
"context"
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type BudgetData struct {
AlwaysNeededData
Transactions []postgres.GetTransactionsForBudgetRow
}
func (h *Handler) budget(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
transactions, err := h.Service.DB.GetTransactionsForBudget(context.Background(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
d := BudgetData{
c.MustGet("data").(AlwaysNeededData),
transactions,
}
c.HTML(http.StatusOK, "budget.html", d)
}
-103
View File
@@ -1,103 +0,0 @@
package http
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type BudgetingData struct {
AlwaysNeededData
Categories []postgres.GetCategoriesWithBalanceRow
Date time.Time
Next time.Time
Previous time.Time
}
func (h *Handler) budgeting(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
now := time.Now()
var year, month int
yearString := c.Param("year")
monthString := c.Param("month")
if yearString != "" && monthString != "" {
year, err = strconv.Atoi(yearString)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
return
}
month, err = strconv.Atoi(monthString)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
return
}
} else {
var monthM time.Month
year, monthM, _ = now.Date()
month = int(monthM)
}
firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location())
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
firstOfPreviousMonth := firstOfMonth.AddDate(0, -1, 0)
params := postgres.GetCategoriesWithBalanceParams{
BudgetID: budgetUUID,
FromDate: firstOfMonth,
ToDate: firstOfNextMonth,
}
categories, err := h.Service.DB.GetCategoriesWithBalance(context.Background(), params)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
d := BudgetingData{
c.MustGet("data").(AlwaysNeededData),
categories,
firstOfMonth,
firstOfNextMonth,
firstOfPreviousMonth,
}
c.HTML(http.StatusOK, "budgeting.html", d)
}
func (h *Handler) clearBudget(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
rows, err := h.Service.DB.DeleteAllAssignments(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
fmt.Printf("Deleted %d assignments\n", rows)
rows, err = h.Service.DB.DeleteAllTransactions(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
fmt.Printf("Deleted %d transactions\n", rows)
}
-203
View File
@@ -1,203 +0,0 @@
package http
import (
"fmt"
"io/fs"
"net/http"
"time"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/web"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Handler handles incoming requests
type Handler struct {
Service *postgres.Repository
TokenVerifier budgeteer.TokenVerifier
CredentialsVerifier *bcrypt.Verifier
}
const (
expiration = 72
authCookie = "authentication"
)
// Serve starts the HTTP Server
func (h *Handler) Serve() {
router := gin.Default()
templates, err := NewTemplates(router.FuncMap)
if err != nil {
panic(err)
}
router.HTMLRender = templates
static, err := fs.Sub(web.Static, "static")
if err != nil {
panic("couldn't open static files")
}
router.StaticFS("/static", http.FS(static))
router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) })
router.GET("/login", h.login)
router.GET("/register", h.register)
withLogin := router.Group("")
withLogin.Use(h.verifyLoginWithRedirect)
withLogin.GET("/dashboard", h.dashboard)
withLogin.GET("/admin", h.admin)
withLogin.GET("/admin/clear-database", h.clearDatabase)
withBudget := router.Group("")
withBudget.Use(h.verifyLoginWithRedirect)
withBudget.Use(h.getImportantData)
withBudget.GET("/budget/:budgetid", h.budgeting)
withBudget.GET("/budget/:budgetid/clear", h.clearBudget)
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
withBudget.GET("/budget/:budgetid/all-accounts", h.budget)
withBudget.GET("/budget/:budgetid/accounts", h.accounts)
withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
api := router.Group("/api/v1")
unauthenticated := api.Group("/user")
unauthenticated.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
unauthenticated.POST("/login", h.loginPost)
unauthenticated.POST("/register", h.registerPost)
authenticated := api.Group("")
authenticated.Use(h.verifyLoginWithRedirect)
user := authenticated.Group("/user")
user.GET("/logout", logout)
budget := authenticated.Group("/budget")
budget.POST("/new", h.newBudget)
transaction := authenticated.Group("/transaction")
transaction.POST("/new", h.newTransaction)
transaction.POST("/import/ynab", h.importYNAB)
router.Run(":1323")
}
func (h *Handler) importYNAB(c *gin.Context) {
budgetID, succ := c.GetPostForm("budget_id")
if !succ {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := NewYNABImport(h.Service.DB, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactionsFile, err := c.FormFile("transactions")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactions, err := transactionsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportTransactions(transactions)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignmentsFile, err := c.FormFile("assignments")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignments, err := assignmentsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportAssignments(assignments)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) newTransaction(c *gin.Context) {
transactionMemo, succ := c.GetPostForm("memo")
if !succ {
c.AbortWithStatus(http.StatusNotAcceptable)
return
}
transactionAccount, succ := c.GetPostForm("account_id")
if !succ {
c.AbortWithStatus(http.StatusNotAcceptable)
return
}
transactionAccountID, err := uuid.Parse(transactionAccount)
if !succ {
c.AbortWithStatus(http.StatusNotAcceptable)
return
}
transactionDate, succ := c.GetPostForm("date")
if !succ {
c.AbortWithStatus(http.StatusNotAcceptable)
return
}
transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
if err != nil {
c.AbortWithStatus(http.StatusNotAcceptable)
return
}
new := postgres.CreateTransactionParams{
Memo: transactionMemo,
Date: transactionDateValue,
Amount: postgres.Numeric{},
AccountID: transactionAccountID,
}
_, err = h.Service.DB.CreateTransaction(c.Request.Context(), new)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) newBudget(c *gin.Context) {
budgetName, succ := c.GetPostForm("name")
if !succ {
c.AbortWithStatus(http.StatusNotAcceptable)
return
}
userID := c.MustGet("token").(budgeteer.Token).GetID()
_, err := h.Service.NewBudget(budgetName, userID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
-121
View File
@@ -1,121 +0,0 @@
package http
import (
"context"
"fmt"
"net/http"
"time"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
)
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) {
tokenString, err := c.Cookie(authCookie)
if err != nil {
return nil, fmt.Errorf("get cookie: %w", err)
}
token, err := h.TokenVerifier.VerifyToken(tokenString)
if err != nil {
c.SetCookie(authCookie, "", -1, "", "", false, false)
return nil, fmt.Errorf("verify token '%s': %w", tokenString, err)
}
return token, nil
}
func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
c.Set("token", token)
c.Next()
}
func (h *Handler) login(c *gin.Context) {
if _, err := h.verifyLogin(c); err == nil {
c.Redirect(http.StatusTemporaryRedirect, "/dashboard")
return
}
c.HTML(http.StatusOK, "login.html", nil)
}
func (h *Handler) register(c *gin.Context) {
if _, err := h.verifyLogin(c); err == nil {
c.Redirect(http.StatusTemporaryRedirect, "/dashboard")
return
}
c.HTML(http.StatusOK, "register.html", nil)
}
func logout(c *gin.Context) {
clearLogin(c)
}
func clearLogin(c *gin.Context) {
c.SetCookie(authCookie, "", -1, "", "", false, true)
}
func (h *Handler) loginPost(c *gin.Context) {
username, _ := c.GetPostForm("username")
password, _ := c.GetPostForm("password")
user, err := h.Service.DB.GetUserByUsername(context.Background(), username)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
if err = h.CredentialsVerifier.Verify(password, user.Password); err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
t, err := h.TokenVerifier.CreateToken(&user)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
}
_, _ = h.Service.DB.UpdateLastLogin(context.Background(), user.ID)
maxAge := (int)((expiration * time.Hour).Seconds())
c.SetCookie(authCookie, t, maxAge, "", "", false, true)
c.JSON(http.StatusOK, map[string]string{
"token": t,
})
}
func (h *Handler) registerPost(c *gin.Context) {
email, _ := c.GetPostForm("email")
password, _ := c.GetPostForm("password")
name, _ := c.GetPostForm("name")
_, err := h.Service.DB.GetUserByUsername(context.Background(), email)
if err == nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
hash, err := h.CredentialsVerifier.Hash(password)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
createUser := postgres.CreateUserParams{
Name: name,
Password: hash,
Email: email,
}
_, err = h.Service.DB.CreateUser(context.Background(), createUser)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
}
-45
View File
@@ -1,45 +0,0 @@
package http
import (
"fmt"
"html/template"
"io/fs"
"git.javil.eu/jacob1123/budgeteer/web"
"github.com/gin-gonic/gin/render"
)
type Templates struct {
templates map[string]*template.Template
}
func NewTemplates(funcMap template.FuncMap) (*Templates, error) {
templates, err := fs.Glob(web.Templates, "*.tpl")
if err != nil {
return nil, err
}
result := &Templates{
templates: make(map[string]*template.Template, 0),
}
pages, err := fs.Glob(web.Templates, "*.html")
for _, page := range pages {
allTemplates := append(templates, page)
tpl, err := template.New(page).Funcs(funcMap).ParseFS(web.Templates, allTemplates...)
fmt.Printf("page: %s, templates: %v\n", page, templates)
if err != nil {
return nil, err
}
result.templates[page] = tpl
}
return result, nil
}
func (tpl *Templates) Instance(name string, obj interface{}) render.Render {
return render.HTML{
Template: tpl.templates[name],
Name: name,
Data: obj,
}
}
-299
View File
@@ -1,299 +0,0 @@
package http
import (
"context"
"encoding/csv"
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/google/uuid"
)
type YNABImport struct {
Context context.Context
accounts []postgres.Account
payees []postgres.Payee
categories []postgres.GetCategoriesRow
categoryGroups []postgres.CategoryGroup
queries *postgres.Queries
budgetID uuid.UUID
}
func NewYNABImport(q *postgres.Queries, budgetID uuid.UUID) (*YNABImport, error) {
accounts, err := q.GetAccounts(context.Background(), budgetID)
if err != nil {
return nil, err
}
payees, err := q.GetPayees(context.Background(), budgetID)
if err != nil {
return nil, err
}
categories, err := q.GetCategories(context.Background(), budgetID)
if err != nil {
return nil, err
}
categoryGroups, err := q.GetCategoryGroups(context.Background(), budgetID)
if err != nil {
return nil, err
}
return &YNABImport{
Context: context.Background(),
accounts: accounts,
payees: payees,
categories: categories,
categoryGroups: categoryGroups,
queries: q,
budgetID: budgetID,
}, nil
}
func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
csv := csv.NewReader(r)
csv.Comma = '\t'
csv.LazyQuotes = true
csvData, err := csv.ReadAll()
if err != nil {
return fmt.Errorf("could not read from tsv: %w", err)
}
count := 0
for _, record := range csvData[1:] {
//"Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
//"Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€
dateString := record[0]
date, err := time.Parse("Jan 2006", dateString)
if err != nil {
return fmt.Errorf("could not parse date %s: %w", dateString, err)
}
categoryGroup, categoryName := record[2], record[3] //also in 1 joined by :
category, err := ynab.GetCategory(categoryGroup, categoryName)
if err != nil {
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
}
amountString := record[4]
amount, err := GetAmount(amountString, "0,00€")
if err != nil {
return fmt.Errorf("could not parse amount %s: %w", amountString, err)
}
if amount.Int.Int64() == 0 {
continue
}
assignment := postgres.CreateAssignmentParams{
Date: date,
CategoryID: category.UUID,
Amount: amount,
}
_, err = ynab.queries.CreateAssignment(ynab.Context, assignment)
if err != nil {
return fmt.Errorf("could not save assignment %v: %w", assignment, err)
}
count++
}
fmt.Printf("Imported %d assignments\n", count)
return nil
}
func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
csv := csv.NewReader(r)
csv.Comma = '\t'
csv.LazyQuotes = true
csvData, err := csv.ReadAll()
if err != nil {
return fmt.Errorf("could not read from tsv: %w", err)
}
count := 0
for _, record := range csvData[1:] {
accountName := record[0]
account, err := ynab.GetAccount(accountName)
if err != nil {
return fmt.Errorf("could not get account %s: %w", accountName, err)
}
//flag := record[1]
dateString := record[2]
date, err := time.Parse("02.01.2006", dateString)
if err != nil {
return fmt.Errorf("could not parse date %s: %w", dateString, err)
}
payeeName := record[3]
payeeID, err := ynab.GetPayee(payeeName)
if err != nil {
return fmt.Errorf("could not get payee %s: %w", payeeName, err)
}
categoryGroup, categoryName := record[5], record[6] //also in 4 joined by :
category, err := ynab.GetCategory(categoryGroup, categoryName)
if err != nil {
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
}
memo := record[7]
outflow := record[8]
inflow := record[9]
amount, err := GetAmount(inflow, outflow)
if err != nil {
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
}
//cleared := record[10]
transaction := postgres.CreateTransactionParams{
Date: date,
Memo: memo,
AccountID: account.ID,
PayeeID: payeeID,
CategoryID: category,
Amount: amount,
}
_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", transaction, err)
}
count++
}
fmt.Printf("Imported %d transactions\n", count)
return nil
}
func trimLastChar(s string) string {
r, size := utf8.DecodeLastRuneInString(s)
if r == utf8.RuneError && (size == 0 || size == 1) {
size = 0
}
return s[:len(s)-size]
}
func GetAmount(inflow string, outflow string) (postgres.Numeric, error) {
// Remove trailing currency
inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
num := postgres.Numeric{}
err := num.Set(inflow)
if err != nil {
return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
}
// if inflow is zero, use outflow
if num.Int.Int64() != 0 {
return num, nil
}
err = num.Set("-" + outflow)
if err != nil {
return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
}
return num, nil
}
func (ynab *YNABImport) GetAccount(name string) (*postgres.Account, error) {
for _, acc := range ynab.accounts {
if acc.Name == name {
return &acc, nil
}
}
account, err := ynab.queries.CreateAccount(ynab.Context, postgres.CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
if err != nil {
return nil, err
}
ynab.accounts = append(ynab.accounts, account)
return &account, nil
}
func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
if name == "" {
return uuid.NullUUID{}, nil
}
for _, pay := range ynab.payees {
if pay.Name == name {
return uuid.NullUUID{UUID: pay.ID, Valid: true}, nil
}
}
payee, err := ynab.queries.CreatePayee(ynab.Context, postgres.CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
if err != nil {
return uuid.NullUUID{}, err
}
ynab.payees = append(ynab.payees, payee)
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
}
func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
if group == "" || name == "" {
return uuid.NullUUID{}, nil
}
for _, category := range ynab.categories {
if category.Name == name && category.Group == group {
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
}
}
for _, categoryGroup := range ynab.categoryGroups {
if categoryGroup.Name == group {
createCategory := postgres.CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
if err != nil {
return uuid.NullUUID{}, err
}
getCategory := postgres.GetCategoriesRow{
ID: category.ID,
CategoryGroupID: category.CategoryGroupID,
Name: category.Name,
Group: categoryGroup.Name,
}
ynab.categories = append(ynab.categories, getCategory)
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
}
}
categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, postgres.CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
if err != nil {
return uuid.NullUUID{}, err
}
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
category, err := ynab.queries.CreateCategory(ynab.Context, postgres.CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
if err != nil {
return uuid.NullUUID{}, err
}
getCategory := postgres.GetCategoriesRow{
ID: category.ID,
CategoryGroupID: category.CategoryGroupID,
Name: category.Name,
Group: categoryGroup.Name,
}
ynab.categories = append(ynab.categories, getCategory)
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
}
+20 -14
View File
@@ -10,11 +10,12 @@ import (
"github.com/google/uuid"
)
// TokenVerifier verifies Tokens
// TokenVerifier verifies Tokens.
type TokenVerifier struct {
Secret string
}
// Token contains everything to authenticate a user
// Token contains everything to authenticate a user.
type Token struct {
username string
name string
@@ -24,10 +25,9 @@ type Token struct {
const (
expiration = 72
secret = "uditapbzuditagscwxuqdflgzpbu´ßiaefnlmzeßtrubiadern"
)
// CreateToken creates a new token from username and name
// CreateToken creates a new token from username and name.
func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"usr": user.Email,
@@ -37,21 +37,27 @@ func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
})
// Generate encoded token and send it as response.
t, err := token.SignedString([]byte(secret))
t, err := token.SignedString([]byte(tv.Secret))
if err != nil {
return "", err
return "", fmt.Errorf("create token: %w", err)
}
return t, nil
}
// VerifyToken verifys a given string-token
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) {
var (
ErrUnexpectedSigningMethod = fmt.Errorf("unexpected signing method")
ErrInvalidToken = fmt.Errorf("token is invalid")
ErrTokenExpired = fmt.Errorf("token has expired")
)
// VerifyToken verifys a given string-token.
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
return nil, fmt.Errorf("method '%v': %w", token.Header["alg"], ErrUnexpectedSigningMethod)
}
return []byte(secret), nil
return []byte(tv.Secret), nil
})
if err != nil {
return nil, fmt.Errorf("parse jwt: %w", err)
@@ -62,7 +68,7 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error
return nil, fmt.Errorf("verify jwt: %w", err)
}
tkn := &Token{
tkn := &Token{ //nolint:forcetypeassert
username: claims["usr"].(string),
name: claims["name"].(string),
expiry: claims["exp"].(float64),
@@ -73,16 +79,16 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error
func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
if !token.Valid {
return nil, fmt.Errorf("Token is not valid")
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("Claims are not of Type MapClaims")
return nil, ErrInvalidToken
}
if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return nil, fmt.Errorf("Claims have expired")
return nil, ErrTokenExpired
}
return claims, nil
+33 -13
View File
@@ -13,7 +13,7 @@ const createAccount = `-- name: CreateAccount :one
INSERT INTO accounts
(name, budget_id)
VALUES ($1, $2)
RETURNING id, budget_id, name
RETURNING id, budget_id, name, on_budget
`
type CreateAccountParams struct {
@@ -24,24 +24,34 @@ type CreateAccountParams struct {
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, createAccount, arg.Name, arg.BudgetID)
var i Account
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
err := row.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.OnBudget,
)
return i, err
}
const getAccount = `-- name: GetAccount :one
SELECT accounts.id, accounts.budget_id, accounts.name FROM accounts
SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts
WHERE accounts.id = $1
`
func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error) {
row := q.db.QueryRowContext(ctx, getAccount, id)
var i Account
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
err := row.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.OnBudget,
)
return i, err
}
const getAccounts = `-- name: GetAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name FROM accounts
SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts
WHERE accounts.budget_id = $1
ORDER BY accounts.name
`
@@ -55,7 +65,12 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
var items []Account
for rows.Next() {
var i Account
if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil {
if err := rows.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.OnBudget,
); err != nil {
return nil, err
}
items = append(items, i)
@@ -70,19 +85,19 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
}
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
SELECT accounts.id, accounts.name, SUM(transactions.amount)::decimal(12,2) as balance
SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
FROM accounts
LEFT JOIN transactions ON transactions.account_id = accounts.id
LEFT JOIN transactions ON transactions.account_id = accounts.id AND transactions.date < NOW()
WHERE accounts.budget_id = $1
AND transactions.date < NOW()
GROUP BY accounts.id, accounts.name
ORDER BY accounts.name
`
type GetAccountsWithBalanceRow struct {
ID uuid.UUID
Name string
Balance Numeric
ID uuid.UUID
Name string
OnBudget bool
Balance Numeric
}
func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID) ([]GetAccountsWithBalanceRow, error) {
@@ -94,7 +109,12 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
var items []GetAccountsWithBalanceRow
for rows.Next() {
var i GetAccountsWithBalanceRow
if err := rows.Scan(&i.ID, &i.Name, &i.Balance); err != nil {
if err := rows.Scan(
&i.ID,
&i.Name,
&i.OnBudget,
&i.Balance,
); err != nil {
return nil, err
}
items = append(items, i)
+34
View File
@@ -52,3 +52,37 @@ func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID)
}
return result.RowsAffected()
}
const getAssignmentsByMonthAndCategory = `-- name: GetAssignmentsByMonthAndCategory :many
SELECT date, category_id, budget_id, amount
FROM assignments_by_month
WHERE assignments_by_month.budget_id = $1
`
func (q *Queries) GetAssignmentsByMonthAndCategory(ctx context.Context, budgetID uuid.UUID) ([]AssignmentsByMonth, error) {
rows, err := q.db.QueryContext(ctx, getAssignmentsByMonthAndCategory, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AssignmentsByMonth
for rows.Next() {
var i AssignmentsByMonth
if err := rows.Scan(
&i.Date,
&i.CategoryID,
&i.BudgetID,
&i.Amount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+79 -10
View File
@@ -5,38 +5,63 @@ package postgres
import (
"context"
"time"
"github.com/google/uuid"
)
const createBudget = `-- name: CreateBudget :one
INSERT INTO budgets
(name, last_modification)
VALUES ($1, NOW())
RETURNING id, name, last_modification
(name, income_category_id, last_modification)
VALUES ($1, $2, NOW())
RETURNING id, name, last_modification, income_category_id
`
func (q *Queries) CreateBudget(ctx context.Context, name string) (Budget, error) {
row := q.db.QueryRowContext(ctx, createBudget, name)
type CreateBudgetParams struct {
Name string
IncomeCategoryID uuid.UUID
}
func (q *Queries) CreateBudget(ctx context.Context, arg CreateBudgetParams) (Budget, error) {
row := q.db.QueryRowContext(ctx, createBudget, arg.Name, arg.IncomeCategoryID)
var i Budget
err := row.Scan(&i.ID, &i.Name, &i.LastModification)
err := row.Scan(
&i.ID,
&i.Name,
&i.LastModification,
&i.IncomeCategoryID,
)
return i, err
}
const deleteBudget = `-- name: DeleteBudget :exec
DELETE FROM budgets WHERE id = $1
`
func (q *Queries) DeleteBudget(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteBudget, id)
return err
}
const getBudget = `-- name: GetBudget :one
SELECT id, name, last_modification FROM budgets
SELECT id, name, last_modification, income_category_id FROM budgets
WHERE id = $1
`
func (q *Queries) GetBudget(ctx context.Context, id uuid.UUID) (Budget, error) {
row := q.db.QueryRowContext(ctx, getBudget, id)
var i Budget
err := row.Scan(&i.ID, &i.Name, &i.LastModification)
err := row.Scan(
&i.ID,
&i.Name,
&i.LastModification,
&i.IncomeCategoryID,
)
return i, err
}
const getBudgetsForUser = `-- name: GetBudgetsForUser :many
SELECT budgets.id, budgets.name, budgets.last_modification FROM budgets
SELECT budgets.id, budgets.name, budgets.last_modification, budgets.income_category_id FROM budgets
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
WHERE user_budgets.user_id = $1
`
@@ -50,7 +75,12 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
var items []Budget
for rows.Next() {
var i Budget
if err := rows.Scan(&i.ID, &i.Name, &i.LastModification); err != nil {
if err := rows.Scan(
&i.ID,
&i.Name,
&i.LastModification,
&i.IncomeCategoryID,
); err != nil {
return nil, err
}
items = append(items, i)
@@ -63,3 +93,42 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
}
return items, nil
}
const getFirstActivity = `-- name: GetFirstActivity :one
SELECT MIN(dates.min_date)::date as min_date
FROM (
SELECT MIN(assignments.date) as min_date
FROM assignments
INNER JOIN categories ON categories.id = assignments.category_id
INNER JOIN category_groups ON category_groups.id = categories.category_group_id
WHERE category_groups.budget_id = $1
UNION
SELECT MIN(transactions.date) as min_date
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
WHERE accounts.budget_id = $1
) dates
`
func (q *Queries) GetFirstActivity(ctx context.Context, budgetID uuid.UUID) (time.Time, error) {
row := q.db.QueryRowContext(ctx, getFirstActivity, budgetID)
var min_date time.Time
err := row.Scan(&min_date)
return min_date, err
}
const setInflowCategory = `-- name: SetInflowCategory :exec
UPDATE budgets
SET income_category_id = $1
WHERE budgets.id = $2
`
type SetInflowCategoryParams struct {
IncomeCategoryID uuid.UUID
ID uuid.UUID
}
func (q *Queries) SetInflowCategory(ctx context.Context, arg SetInflowCategoryParams) error {
_, err := q.db.ExecContext(ctx, setInflowCategory, arg.IncomeCategoryID, arg.ID)
return err
}
+43 -20
View File
@@ -2,38 +2,61 @@ package postgres
import (
"context"
"database/sql"
"fmt"
"github.com/google/uuid"
)
// Budget returns a budget for a given id.
func (s *Repository) Budget(id uuid.UUID) (*Budget, error) {
budget, err := s.DB.GetBudget(context.Background(), id)
// NewBudget creates a budget and adds it to the current user.
func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
tx, err := s.BeginTx(context, &sql.TxOptions{})
if err != nil {
return nil, err
return nil, fmt.Errorf("begin transaction: %w", err)
}
return &budget, nil
}
func (s *Repository) BudgetsForUser(id uuid.UUID) ([]Budget, error) {
budgets, err := s.DB.GetBudgetsForUser(context.Background(), id)
transaction := s.WithTx(tx)
budget, err := transaction.CreateBudget(context, CreateBudgetParams{
Name: name,
IncomeCategoryID: uuid.New(),
})
if err != nil {
return nil, err
}
return budgets, nil
}
func (s *Repository) NewBudget(name string, userID uuid.UUID) (*Budget, error) {
budget, err := s.DB.CreateBudget(context.Background(), name)
if err != nil {
return nil, err
return nil, fmt.Errorf("create budget: %w", err)
}
ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
_, err = s.DB.LinkBudgetToUser(context.Background(), ub)
_, err = transaction.LinkBudgetToUser(context, ub)
if err != nil {
return nil, err
return nil, fmt.Errorf("link budget to user: %w", err)
}
group, err := transaction.CreateCategoryGroup(context, CreateCategoryGroupParams{
Name: "Inflow",
BudgetID: budget.ID,
})
if err != nil {
return nil, fmt.Errorf("create inflow category_group: %w", err)
}
cat, err := transaction.CreateCategory(context, CreateCategoryParams{
Name: "Ready to Assign",
CategoryGroupID: group.ID,
})
if err != nil {
return nil, fmt.Errorf("create ready to assign category: %w", err)
}
err = transaction.SetInflowCategory(context, SetInflowCategoryParams{
IncomeCategoryID: cat.ID,
ID: budget.ID,
})
if err != nil {
return nil, fmt.Errorf("set inflow category: %w", err)
}
err = tx.Commit()
if err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &budget, nil
+42 -76
View File
@@ -5,7 +5,6 @@ package postgres
import (
"context"
"time"
"github.com/google/uuid"
)
@@ -52,6 +51,7 @@ const getCategories = `-- name: GetCategories :many
SELECT categories.id, categories.category_group_id, categories.name, category_groups.name as group FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1
ORDER BY category_groups.name, categories.name
`
type GetCategoriesRow struct {
@@ -89,81 +89,6 @@ func (q *Queries) GetCategories(ctx context.Context, budgetID uuid.UUID) ([]GetC
return items, nil
}
const getCategoriesWithBalance = `-- name: GetCategoriesWithBalance :many
SELECT categories.id, categories.name, category_groups.name as group,
(COALESCE(
(
SELECT SUM(a_hist.amount)
FROM assignments a_hist
WHERE categories.id = a_hist.category_id
AND a_hist.date < $1
)
, 0)+COALESCE(
(
SELECT SUM(t_hist.amount)
FROM transactions t_hist
WHERE categories.id = t_hist.category_id
AND t_hist.date < $1
)
, 0))::decimal(12,2) as balance,
COALESCE(
(
SELECT SUM(t_this.amount)
FROM transactions t_this
WHERE categories.id = t_this.category_id
AND t_this.date BETWEEN $1 AND $2
)
, 0)::decimal(12,2) as activity
FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $3
GROUP BY categories.id, categories.name, category_groups.name
ORDER BY category_groups.name, categories.name
`
type GetCategoriesWithBalanceParams struct {
FromDate time.Time
ToDate time.Time
BudgetID uuid.UUID
}
type GetCategoriesWithBalanceRow struct {
ID uuid.UUID
Name string
Group string
Balance Numeric
Activity Numeric
}
func (q *Queries) GetCategoriesWithBalance(ctx context.Context, arg GetCategoriesWithBalanceParams) ([]GetCategoriesWithBalanceRow, error) {
rows, err := q.db.QueryContext(ctx, getCategoriesWithBalance, arg.FromDate, arg.ToDate, arg.BudgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCategoriesWithBalanceRow
for rows.Next() {
var i GetCategoriesWithBalanceRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Group,
&i.Balance,
&i.Activity,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getCategoryGroups = `-- name: GetCategoryGroups :many
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
WHERE category_groups.budget_id = $1
@@ -191,3 +116,44 @@ func (q *Queries) GetCategoryGroups(ctx context.Context, budgetID uuid.UUID) ([]
}
return items, nil
}
const searchCategories = `-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1
AND categories.name LIKE $2
ORDER BY category_groups.name, categories.name
`
type SearchCategoriesParams struct {
BudgetID uuid.UUID
Search string
}
type SearchCategoriesRow struct {
Name interface{}
ID uuid.UUID
}
func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) {
rows, err := q.db.QueryContext(ctx, searchCategories, arg.BudgetID, arg.Search)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SearchCategoriesRow
for rows.Next() {
var i SearchCategoriesRow
if err := rows.Scan(&i.Name, &i.ID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+15 -8
View File
@@ -5,25 +5,32 @@ import (
"embed"
"fmt"
_ "github.com/jackc/pgx/v4/stdlib"
_ "github.com/jackc/pgx/v4/stdlib" // needed for pg connection
"github.com/pressly/goose/v3"
)
//go:embed schema/*.sql
var migrations embed.FS
// Connect to a database
func Connect(server string, user string, password string, database string) (*Queries, *sql.DB, error) {
connString := fmt.Sprintf("postgres://%s:%s@%s/%s", user, password, server, database)
conn, err := sql.Open("pgx", connString)
type Database struct {
*Queries
*sql.DB
}
// Connect connects to a database.
func Connect(typ string, connString string) (*Database, error) {
conn, err := sql.Open(typ, connString)
if err != nil {
return nil, nil, err
return nil, fmt.Errorf("open connection: %w", err)
}
goose.SetBaseFS(migrations)
if err = goose.Up(conn, "schema"); err != nil {
return nil, nil, err
return nil, fmt.Errorf("migrate: %w", err)
}
return New(conn), conn, nil
return &Database{
New(conn),
conn,
}, nil
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by sqlc. DO NOT EDIT.
// source: cumultative-balances.sql
package postgres
import (
"context"
"time"
"github.com/google/uuid"
)
const getCumultativeBalances = `-- name: GetCumultativeBalances :many
SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
COALESCE(ass.amount, 0)::decimal(12,2) as assignments, SUM(ass.amount) OVER (PARTITION BY ass.category_id ORDER BY ass.date)::decimal(12,2) as assignments_cum,
COALESCE(tra.amount, 0)::decimal(12,2) as transactions, SUM(tra.amount) OVER (PARTITION BY tra.category_id ORDER BY tra.date)::decimal(12,2) as transactions_cum
FROM assignments_by_month as ass
FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
WHERE (ass.budget_id IS NULL OR ass.budget_id = $1) AND (tra.budget_id IS NULL OR tra.budget_id = $1)
ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id)
`
type GetCumultativeBalancesRow struct {
Date time.Time
CategoryID uuid.UUID
Assignments Numeric
AssignmentsCum Numeric
Transactions Numeric
TransactionsCum Numeric
}
func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) {
rows, err := q.db.QueryContext(ctx, getCumultativeBalances, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCumultativeBalancesRow
for rows.Next() {
var i GetCumultativeBalancesRow
if err := rows.Scan(
&i.Date,
&i.CategoryID,
&i.Assignments,
&i.AssignmentsCum,
&i.Transactions,
&i.TransactionsCum,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+39
View File
@@ -4,15 +4,37 @@ package postgres
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
type TransactionStatus string
const (
TransactionStatusReconciled TransactionStatus = "Reconciled"
TransactionStatusCleared TransactionStatus = "Cleared"
TransactionStatusUncleared TransactionStatus = "Uncleared"
)
func (e *TransactionStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = TransactionStatus(s)
case string:
*e = TransactionStatus(s)
default:
return fmt.Errorf("unsupported scan type for TransactionStatus: %T", src)
}
return nil
}
type Account struct {
ID uuid.UUID
BudgetID uuid.UUID
Name string
OnBudget bool
}
type Assignment struct {
@@ -23,10 +45,18 @@ type Assignment struct {
Amount Numeric
}
type AssignmentsByMonth struct {
Date time.Time
CategoryID uuid.UUID
BudgetID uuid.UUID
Amount int64
}
type Budget struct {
ID uuid.UUID
Name string
LastModification sql.NullTime
IncomeCategoryID uuid.UUID
}
type Category struct {
@@ -55,6 +85,15 @@ type Transaction struct {
AccountID uuid.UUID
CategoryID uuid.NullUUID
PayeeID uuid.NullUUID
GroupID uuid.NullUUID
Status TransactionStatus
}
type TransactionsByMonth struct {
Date time.Time
CategoryID uuid.NullUUID
BudgetID uuid.UUID
Amount int64
}
type User struct {
+111 -2
View File
@@ -1,12 +1,24 @@
package postgres
import "github.com/jackc/pgtype"
import (
"fmt"
"math/big"
"github.com/jackc/pgtype"
)
type Numeric struct {
pgtype.Numeric
}
func NewZeroNumeric() Numeric {
return Numeric{pgtype.Numeric{Exp: 0, Int: big.NewInt(0), Status: pgtype.Present, NaN: false}}
}
func (n Numeric) GetFloat64() float64 {
if n.Status != pgtype.Present {
return 0
}
var balance float64
err := n.AssignTo(&balance)
if err != nil {
@@ -15,7 +27,104 @@ func (n Numeric) GetFloat64() float64 {
return balance
}
func (n Numeric) GetPositive() bool {
func (n Numeric) IsPositive() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float >= 0
}
func (n Numeric) IsZero() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float == 0
}
func (n Numeric) MatchExp(exp int32) Numeric {
diffExp := n.Exp - exp
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd
return Numeric{pgtype.Numeric{
Exp: exp,
Int: big.NewInt(0).Mul(n.Int, factor),
Status: n.Status,
NaN: n.NaN,
}}
}
func (n Numeric) Sub(other Numeric) Numeric {
left := n
right := other
if n.Exp < other.Exp {
right = other.MatchExp(n.Exp)
} else if n.Exp > other.Exp {
left = n.MatchExp(other.Exp)
}
if left.Exp == right.Exp {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Sub(left.Int, right.Int),
}}
}
panic("Cannot subtract with different exponents")
}
func (n Numeric) Add(other Numeric) Numeric {
left := n
right := other
if n.Exp < other.Exp {
right = other.MatchExp(n.Exp)
} else if n.Exp > other.Exp {
left = n.MatchExp(other.Exp)
}
if left.Exp == right.Exp {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Add(left.Int, right.Int),
}}
}
panic("Cannot add with different exponents")
}
func (n Numeric) MarshalJSON() ([]byte, error) {
if n.Int.Int64() == 0 {
return []byte("\"0\""), nil
}
s := fmt.Sprintf("%d", n.Int)
bytes := []byte(s)
exp := n.Exp
for exp > 0 {
bytes = append(bytes, byte('0'))
exp--
}
if exp == 0 {
return bytes, nil
}
length := int32(len(bytes))
var bytesWithSeparator []byte
exp = -exp
for length <= exp {
bytes = append(bytes, byte('0'))
length++
}
split := length - exp
bytesWithSeparator = append(bytesWithSeparator, bytes[:split]...)
if split == 1 && n.Int.Int64() < 0 {
bytesWithSeparator = append(bytesWithSeparator, byte('0'))
}
bytesWithSeparator = append(bytesWithSeparator, byte('.'))
bytesWithSeparator = append(bytesWithSeparator, bytes[split:]...)
return bytesWithSeparator, nil
}
+36
View File
@@ -31,6 +31,7 @@ func (q *Queries) CreatePayee(ctx context.Context, arg CreatePayeeParams) (Payee
const getPayees = `-- name: GetPayees :many
SELECT payees.id, payees.budget_id, payees.name FROM payees
WHERE payees.budget_id = $1
ORDER BY name
`
func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, error) {
@@ -55,3 +56,38 @@ func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, e
}
return items, nil
}
const searchPayees = `-- name: SearchPayees :many
SELECT payees.id, payees.budget_id, payees.name FROM payees
WHERE payees.budget_id = $1
AND payees.name LIKE $2
ORDER BY payees.name
`
type SearchPayeesParams struct {
BudgetID uuid.UUID
Search string
}
func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]Payee, error) {
rows, err := q.db.QueryContext(ctx, searchPayees, arg.BudgetID, arg.Search)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Payee
for rows.Next() {
var i Payee
if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+2 -3
View File
@@ -14,10 +14,9 @@ WHERE accounts.budget_id = $1
ORDER BY accounts.name;
-- name: GetAccountsWithBalance :many
SELECT accounts.id, accounts.name, SUM(transactions.amount)::decimal(12,2) as balance
SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
FROM accounts
LEFT JOIN transactions ON transactions.account_id = accounts.id
LEFT JOIN transactions ON transactions.account_id = accounts.id AND transactions.date < NOW()
WHERE accounts.budget_id = $1
AND transactions.date < NOW()
GROUP BY accounts.id, accounts.name
ORDER BY accounts.name;
+5
View File
@@ -11,3 +11,8 @@ DELETE FROM assignments
USING categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE categories.id = assignments.category_id AND category_groups.budget_id = @budget_id;
-- name: GetAssignmentsByMonthAndCategory :many
SELECT *
FROM assignments_by_month
WHERE assignments_by_month.budget_id = @budget_id;
+26 -3
View File
@@ -1,9 +1,14 @@
-- name: CreateBudget :one
INSERT INTO budgets
(name, last_modification)
VALUES ($1, NOW())
(name, income_category_id, last_modification)
VALUES ($1, $2, NOW())
RETURNING *;
-- name: SetInflowCategory :exec
UPDATE budgets
SET income_category_id = $1
WHERE budgets.id = $2;
-- name: GetBudgetsForUser :many
SELECT budgets.* FROM budgets
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
@@ -11,4 +16,22 @@ WHERE user_budgets.user_id = $1;
-- name: GetBudget :one
SELECT * FROM budgets
WHERE id = $1;
WHERE id = $1;
-- name: GetFirstActivity :one
SELECT MIN(dates.min_date)::date as min_date
FROM (
SELECT MIN(assignments.date) as min_date
FROM assignments
INNER JOIN categories ON categories.id = assignments.category_id
INNER JOIN category_groups ON category_groups.id = categories.category_group_id
WHERE category_groups.budget_id = @budget_id
UNION
SELECT MIN(transactions.date) as min_date
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
WHERE accounts.budget_id = @budget_id
) dates;
-- name: DeleteBudget :exec
DELETE FROM budgets WHERE id = $1;
+7 -29
View File
@@ -17,35 +17,13 @@ RETURNING *;
-- name: GetCategories :many
SELECT categories.*, category_groups.name as group FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1;
WHERE category_groups.budget_id = $1
ORDER BY category_groups.name, categories.name;
-- name: GetCategoriesWithBalance :many
SELECT categories.id, categories.name, category_groups.name as group,
(COALESCE(
(
SELECT SUM(a_hist.amount)
FROM assignments a_hist
WHERE categories.id = a_hist.category_id
AND a_hist.date < @from_date
)
, 0)+COALESCE(
(
SELECT SUM(t_hist.amount)
FROM transactions t_hist
WHERE categories.id = t_hist.category_id
AND t_hist.date < @from_date
)
, 0))::decimal(12,2) as balance,
COALESCE(
(
SELECT SUM(t_this.amount)
FROM transactions t_this
WHERE categories.id = t_this.category_id
AND t_this.date BETWEEN @from_date AND @to_date
)
, 0)::decimal(12,2) as activity
FROM categories
-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = @budget_id
GROUP BY categories.id, categories.name, category_groups.name
ORDER BY category_groups.name, categories.name;
AND categories.name LIKE @search
ORDER BY category_groups.name, categories.name;
--ORDER BY levenshtein(payees.name, $2);
@@ -0,0 +1,8 @@
-- name: GetCumultativeBalances :many
SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
COALESCE(ass.amount, 0)::decimal(12,2) as assignments, SUM(ass.amount) OVER (PARTITION BY ass.category_id ORDER BY ass.date)::decimal(12,2) as assignments_cum,
COALESCE(tra.amount, 0)::decimal(12,2) as transactions, SUM(tra.amount) OVER (PARTITION BY tra.category_id ORDER BY tra.date)::decimal(12,2) as transactions_cum
FROM assignments_by_month as ass
FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
WHERE (ass.budget_id IS NULL OR ass.budget_id = @budget_id) AND (tra.budget_id IS NULL OR tra.budget_id = @budget_id)
ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id);
+9 -1
View File
@@ -6,4 +6,12 @@ RETURNING *;
-- name: GetPayees :many
SELECT payees.* FROM payees
WHERE payees.budget_id = $1;
WHERE payees.budget_id = $1
ORDER BY name;
-- name: SearchPayees :many
SELECT payees.* FROM payees
WHERE payees.budget_id = @budget_id
AND payees.name LIKE @search
ORDER BY payees.name;
--ORDER BY levenshtein(payees.name, $2);
+40 -6
View File
@@ -1,11 +1,29 @@
-- name: GetTransaction :one
SELECT * FROM transactions
WHERE id = $1;
-- name: CreateTransaction :one
INSERT INTO transactions
(date, memo, amount, account_id, payee_id, category_id)
VALUES ($1, $2, $3, $4, $5, $6)
(date, memo, amount, account_id, payee_id, category_id, group_id, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: UpdateTransaction :exec
UPDATE transactions
SET date = $1,
memo = $2,
amount = $3,
account_id = $4,
payee_id = $5,
category_id = $6
WHERE id = $7;
-- name: DeleteTransaction :exec
DELETE FROM transactions
WHERE id = $1;
-- name: GetTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
@@ -17,8 +35,19 @@ ORDER BY transactions.date DESC
LIMIT 200;
-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
(
SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id
) as transfer_account
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id
@@ -32,4 +61,9 @@ LIMIT 200;
DELETE FROM transactions
USING accounts
WHERE accounts.budget_id = @budget_id
AND accounts.id = transactions.account_id;
AND accounts.id = transactions.account_id;
-- name: GetTransactionsByMonthAndCategory :many
SELECT *
FROM transactions_by_month
WHERE transactions_by_month.budget_id = @budget_id;
-17
View File
@@ -1,17 +0,0 @@
package postgres
import "database/sql"
// Repository represents a PostgreSQL implementation of all ModelServices
type Repository struct {
DB *Queries
LegacyDB *sql.DB
}
func NewRepository(queries *Queries, db *sql.DB) (*Repository, error) {
repo := &Repository{
DB: queries,
LegacyDB: db,
}
return repo, nil
}
+9
View File
@@ -0,0 +1,9 @@
-- +goose Up
CREATE TABLE budgets (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
name text NOT NULL,
last_modification timestamp with time zone
);
-- +goose Down
DROP TABLE budgets;
+11
View File
@@ -0,0 +1,11 @@
-- +goose Up
CREATE TABLE users (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
email text NOT NULL,
name text NOT NULL,
password text NOT NULL,
last_login timestamp with time zone
);
-- +goose Down
DROP TABLE users;
+8
View File
@@ -0,0 +1,8 @@
-- +goose Up
CREATE TABLE user_budgets (
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE
);
-- +goose Down
DROP TABLE user_budgets;
+10
View File
@@ -0,0 +1,10 @@
-- +goose Up
CREATE TABLE accounts (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
name varchar(50) NOT NULL,
on_budget boolean DEFAULT TRUE NOT NULL
);
-- +goose Down
DROP TABLE accounts;
+9
View File
@@ -0,0 +1,9 @@
-- +goose Up
CREATE TABLE payees (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
name varchar(50) NOT NULL
);
-- +goose Down
DROP TABLE payees;
+9
View File
@@ -0,0 +1,9 @@
-- +goose Up
CREATE TABLE category_groups (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
name varchar(50) NOT NULL
);
-- +goose Down
DROP TABLE category_groups;
+12
View File
@@ -0,0 +1,12 @@
-- +goose Up
CREATE TABLE categories (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
category_group_id uuid NOT NULL REFERENCES category_groups (id) ON DELETE CASCADE,
name varchar(50) NOT NULL
);
ALTER TABLE budgets ADD COLUMN
income_category_id uuid NOT NULL REFERENCES categories (id) DEFERRABLE INITIALLY DEFERRED;
-- +goose Down
ALTER TABLE budgets DROP COLUMN income_category_id;
DROP TABLE categories;
+16
View File
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE transactions (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
date date NOT NULL,
memo text NOT NULL,
amount decimal(12,2) NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (id),
category_id uuid REFERENCES categories (id),
payee_id uuid REFERENCES payees (id)
);
ALTER TABLE "transactions" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
ALTER TABLE "transactions" ADD FOREIGN KEY ("payee_id") REFERENCES "payees" ("id");
ALTER TABLE "transactions" ADD FOREIGN KEY ("category_id") REFERENCES "categories" ("id");
-- +goose Down
DROP TABLE transactions;
+17
View File
@@ -0,0 +1,17 @@
-- +goose Up
CREATE VIEW transactions_by_month AS
SELECT date_trunc('month', transactions.date)::date as date, transactions.category_id, accounts.budget_id, SUM(amount) as amount
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
GROUP BY date_trunc('month', transactions.date), transactions.category_id, accounts.budget_id;
CREATE VIEW assignments_by_month AS
SELECT date_trunc('month', assignments.date)::date as date, assignments.category_id, category_groups.budget_id, SUM(amount) as amount
FROM assignments
INNER JOIN categories ON categories.id = assignments.category_id
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
GROUP BY date_trunc('month', assignments.date), assignments.category_id, category_groups.budget_id;
-- +goose Down
DROP VIEW transactions_by_month;
DROP VIEW assignments_by_month;
+5
View File
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE transactions ADD COLUMN group_id uuid NULL;
-- +goose Down
ALTER TABLE transactions DROP COLUMN group_id;
@@ -0,0 +1,12 @@
-- +goose Up
CREATE TYPE transaction_status AS ENUM (
'Reconciled',
'Cleared',
'Uncleared'
);
ALTER TABLE transactions ADD COLUMN status transaction_status NOT NULL DEFAULT 'Uncleared';
-- +goose Down
ALTER TABLE transactions DROP COLUMN status;
DROP TYPE transaction_status;
@@ -0,0 +1,5 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "fuzzystrmatch";
-- +goose Down
DROP EXTENSION "fuzzystrmatch";
-66
View File
@@ -1,66 +0,0 @@
-- +goose Up
CREATE TABLE budgets (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
name text NOT NULL,
last_modification timestamp with time zone
);
CREATE TABLE users (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
email text NOT NULL,
name text NOT NULL,
password text NOT NULL,
last_login timestamp with time zone
);
CREATE TABLE user_budgets (
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE
);
CREATE TABLE accounts (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
name varchar(50) NOT NULL
);
CREATE TABLE payees (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
name varchar(50) NOT NULL
);
CREATE TABLE category_groups (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
name varchar(50) NOT NULL
);
CREATE TABLE categories (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
category_group_id uuid NOT NULL REFERENCES category_groups (id) ON DELETE CASCADE,
name varchar(50) NOT NULL
);
CREATE TABLE transactions (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
date date NOT NULL,
memo text NOT NULL,
amount decimal(12,2) NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (id),
category_id uuid REFERENCES categories (id),
payee_id uuid REFERENCES payees (id)
);
ALTER TABLE "transactions" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
ALTER TABLE "transactions" ADD FOREIGN KEY ("payee_id") REFERENCES "payees" ("id");
ALTER TABLE "transactions" ADD FOREIGN KEY ("category_id") REFERENCES "categories" ("id");
-- +goose Down
DROP TABLE transactions;
DROP TABLE accounts;
DROP TABLE payees;
DROP TABLE categories;
DROP TABLE category_groups;
DROP TABLE user_budgets;
DROP TABLE budgets;
DROP TABLE users;
+141 -14
View File
@@ -12,9 +12,9 @@ import (
const createTransaction = `-- name: CreateTransaction :one
INSERT INTO transactions
(date, memo, amount, account_id, payee_id, category_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, date, memo, amount, account_id, category_id, payee_id
(date, memo, amount, account_id, payee_id, category_id, group_id, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id, status
`
type CreateTransactionParams struct {
@@ -24,6 +24,8 @@ type CreateTransactionParams struct {
AccountID uuid.UUID
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
GroupID uuid.NullUUID
Status TransactionStatus
}
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {
@@ -34,6 +36,8 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
arg.AccountID,
arg.PayeeID,
arg.CategoryID,
arg.GroupID,
arg.Status,
)
var i Transaction
err := row.Scan(
@@ -44,6 +48,8 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
&i.AccountID,
&i.CategoryID,
&i.PayeeID,
&i.GroupID,
&i.Status,
)
return i, err
}
@@ -63,9 +69,86 @@ func (q *Queries) DeleteAllTransactions(ctx context.Context, budgetID uuid.UUID)
return result.RowsAffected()
}
const deleteTransaction = `-- name: DeleteTransaction :exec
DELETE FROM transactions
WHERE id = $1
`
func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteTransaction, id)
return err
}
const getTransaction = `-- name: GetTransaction :one
SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id, status FROM transactions
WHERE id = $1
`
func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (Transaction, error) {
row := q.db.QueryRowContext(ctx, getTransaction, id)
var i Transaction
err := row.Scan(
&i.ID,
&i.Date,
&i.Memo,
&i.Amount,
&i.AccountID,
&i.CategoryID,
&i.PayeeID,
&i.GroupID,
&i.Status,
)
return i, err
}
const getTransactionsByMonthAndCategory = `-- name: GetTransactionsByMonthAndCategory :many
SELECT date, category_id, budget_id, amount
FROM transactions_by_month
WHERE transactions_by_month.budget_id = $1
`
func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetID uuid.UUID) ([]TransactionsByMonth, error) {
rows, err := q.db.QueryContext(ctx, getTransactionsByMonthAndCategory, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TransactionsByMonth
for rows.Next() {
var i TransactionsByMonth
if err := rows.Scan(
&i.Date,
&i.CategoryID,
&i.BudgetID,
&i.Amount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
(
SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id
) as transfer_account
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id
@@ -77,14 +160,17 @@ LIMIT 200
`
type GetTransactionsForAccountRow struct {
ID uuid.UUID
Date time.Time
Memo string
Amount Numeric
Account string
Payee string
CategoryGroup string
Category string
ID uuid.UUID
Date time.Time
Memo string
Amount Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string
Payee string
CategoryGroup string
Category string
TransferAccount interface{}
}
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) {
@@ -101,10 +187,13 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
&i.Date,
&i.Memo,
&i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.Payee,
&i.CategoryGroup,
&i.Category,
&i.TransferAccount,
); err != nil {
return nil, err
}
@@ -120,7 +209,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
}
const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
@@ -137,6 +226,8 @@ type GetTransactionsForBudgetRow struct {
Date time.Time
Memo string
Amount Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string
Payee string
CategoryGroup string
@@ -157,6 +248,8 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
&i.Date,
&i.Memo,
&i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.Payee,
&i.CategoryGroup,
@@ -174,3 +267,37 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
}
return items, nil
}
const updateTransaction = `-- name: UpdateTransaction :exec
UPDATE transactions
SET date = $1,
memo = $2,
amount = $3,
account_id = $4,
payee_id = $5,
category_id = $6
WHERE id = $7
`
type UpdateTransactionParams struct {
Date time.Time
Memo string
Amount Numeric
AccountID uuid.UUID
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
ID uuid.UUID
}
func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionParams) error {
_, err := q.db.ExecContext(ctx, updateTransaction,
arg.Date,
arg.Memo,
arg.Amount,
arg.AccountID,
arg.PayeeID,
arg.CategoryID,
arg.ID,
)
return err
}
+405
View File
@@ -0,0 +1,405 @@
package postgres
import (
"context"
"encoding/csv"
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
)
type YNABImport struct {
accounts []Account
payees []Payee
categories []GetCategoriesRow
categoryGroups []CategoryGroup
queries *Queries
budgetID uuid.UUID
}
func NewYNABImport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABImport, error) {
accounts, err := queries.GetAccounts(context, budgetID)
if err != nil {
return nil, err
}
payees, err := queries.GetPayees(context, budgetID)
if err != nil {
return nil, err
}
categories, err := queries.GetCategories(context, budgetID)
if err != nil {
return nil, err
}
categoryGroups, err := queries.GetCategoryGroups(context, budgetID)
if err != nil {
return nil, err
}
return &YNABImport{
accounts: accounts,
payees: payees,
categories: categories,
categoryGroups: categoryGroups,
queries: queries,
budgetID: budgetID,
}, nil
}
// ImportAssignments expects a TSV-file as exported by YNAB in the following format:
// "Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
// "Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€
//
// Activity and Available are not imported, since they are determined by the transactions and historic assignments.
func (ynab *YNABImport) ImportAssignments(context context.Context, r io.Reader) error {
csv := csv.NewReader(r)
csv.Comma = '\t'
csv.LazyQuotes = true
csvData, err := csv.ReadAll()
if err != nil {
return fmt.Errorf("read from tsv: %w", err)
}
count := 0
for _, record := range csvData[1:] {
dateString := record[0]
date, err := time.Parse("Jan 2006", dateString)
if err != nil {
return fmt.Errorf("parse date %s: %w", dateString, err)
}
categoryGroup, categoryName := record[2], record[3] // also in 1 joined by :
category, err := ynab.GetCategory(context, categoryGroup, categoryName)
if err != nil {
return fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err)
}
amountString := record[4]
amount, err := GetAmount(amountString, "0,00€")
if err != nil {
return fmt.Errorf("parse amount %s: %w", amountString, err)
}
if amount.Int.Int64() == 0 {
continue
}
assignment := CreateAssignmentParams{
Date: date,
CategoryID: category.UUID,
Amount: amount,
}
_, err = ynab.queries.CreateAssignment(context, assignment)
if err != nil {
return fmt.Errorf("save assignment %v: %w", assignment, err)
}
count++
}
fmt.Printf("Imported %d assignments\n", count)
return nil
}
type Transfer struct {
CreateTransactionParams
TransferToAccount *Account
FromAccount string
ToAccount string
}
// ImportTransactions expects a TSV-file as exported by YNAB.
func (ynab *YNABImport) ImportTransactions(context context.Context, r io.Reader) error {
csv := csv.NewReader(r)
csv.Comma = '\t'
csv.LazyQuotes = true
csvData, err := csv.ReadAll()
if err != nil {
return fmt.Errorf("read from tsv: %w", err)
}
var openTransfers []Transfer
count := 0
for _, record := range csvData[1:] {
transaction, err := ynab.GetTransaction(context, record)
if err != nil {
return err
}
payeeName := record[3]
// Transaction is a transfer
if strings.HasPrefix(payeeName, "Transfer : ") {
err = ynab.ImportTransferTransaction(context, payeeName, transaction.CreateTransactionParams,
&openTransfers, transaction.Account, transaction.Amount)
} else {
err = ynab.ImportRegularTransaction(context, payeeName, transaction.CreateTransactionParams)
}
if err != nil {
return err
}
count++
}
for _, openTransfer := range openTransfers {
fmt.Printf("Saving unmatched transfer from %s to %s on %s over %f as regular transaction\n",
openTransfer.FromAccount, openTransfer.ToAccount, openTransfer.Date, openTransfer.Amount.GetFloat64())
_, err = ynab.queries.CreateTransaction(context, openTransfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err)
}
}
fmt.Printf("Imported %d transactions\n", count)
return nil
}
type NewTransaction struct {
CreateTransactionParams
Account *Account
}
func (ynab *YNABImport) GetTransaction(context context.Context, record []string) (NewTransaction, error) {
accountName := record[0]
account, err := ynab.GetAccount(context, accountName)
if err != nil {
return NewTransaction{}, fmt.Errorf("get account %s: %w", accountName, err)
}
// flag := record[1]
dateString := record[2]
date, err := time.Parse("02.01.2006", dateString)
if err != nil {
return NewTransaction{}, fmt.Errorf("parse date %s: %w", dateString, err)
}
categoryGroup, categoryName := record[5], record[6] // also in 4 joined by :
category, err := ynab.GetCategory(context, categoryGroup, categoryName)
if err != nil {
return NewTransaction{}, fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err)
}
memo := record[7]
outflow := record[8]
inflow := record[9]
amount, err := GetAmount(inflow, outflow)
if err != nil {
return NewTransaction{}, fmt.Errorf("parse amount from (%s/%s): %w", inflow, outflow, err)
}
statusEnum := TransactionStatusUncleared
status := record[10]
switch status {
case "Cleared":
statusEnum = TransactionStatusCleared
case "Reconciled":
statusEnum = TransactionStatusReconciled
case "Uncleared":
}
return NewTransaction{
CreateTransactionParams: CreateTransactionParams{
Date: date,
Memo: memo,
AccountID: account.ID,
CategoryID: category,
Amount: amount,
Status: statusEnum,
},
Account: account,
}, nil
}
func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeName string,
transaction CreateTransactionParams) error {
payeeID, err := ynab.GetPayee(context, payeeName)
if err != nil {
return fmt.Errorf("get payee %s: %w", payeeName, err)
}
transaction.PayeeID = payeeID
_, err = ynab.queries.CreateTransaction(context, transaction)
if err != nil {
return fmt.Errorf("save transaction %v: %w", transaction, err)
}
return nil
}
func (ynab *YNABImport) ImportTransferTransaction(context context.Context, payeeName string,
transaction CreateTransactionParams, openTransfers *[]Transfer,
account *Account, amount Numeric) error {
transferToAccountName := payeeName[11:]
transferToAccount, err := ynab.GetAccount(context, transferToAccountName)
if err != nil {
return fmt.Errorf("get transfer account %s: %w", transferToAccountName, err)
}
transfer := Transfer{
transaction,
transferToAccount,
account.Name,
transferToAccountName,
}
found := false
for i, openTransfer := range *openTransfers {
if openTransfer.TransferToAccount.ID != transfer.AccountID {
continue
}
if openTransfer.AccountID != transfer.TransferToAccount.ID {
continue
}
if openTransfer.Amount.GetFloat64() != -1*transfer.Amount.GetFloat64() {
continue
}
fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
transfers := *openTransfers
transfers[i] = transfers[len(transfers)-1]
*openTransfers = transfers[:len(transfers)-1]
found = true
groupID := uuid.New()
transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
_, err = ynab.queries.CreateTransaction(context, transfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("save transaction %v: %w", transfer.CreateTransactionParams, err)
}
_, err = ynab.queries.CreateTransaction(context, openTransfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err)
}
break
}
if !found {
*openTransfers = append(*openTransfers, transfer)
}
return nil
}
func trimLastChar(s string) string {
r, size := utf8.DecodeLastRuneInString(s)
if r == utf8.RuneError && (size == 0 || size == 1) {
size = 0
}
return s[:len(s)-size]
}
func GetAmount(inflow string, outflow string) (Numeric, error) {
// Remove trailing currency
inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
num := Numeric{}
err := num.Set(inflow)
if err != nil {
return num, fmt.Errorf("parse inflow %s: %w", inflow, err)
}
// if inflow is zero, use outflow
if num.Int.Int64() != 0 {
return num, nil
}
err = num.Set("-" + outflow)
if err != nil {
return num, fmt.Errorf("parse outflow %s: %w", inflow, err)
}
return num, nil
}
func (ynab *YNABImport) GetAccount(context context.Context, name string) (*Account, error) {
for _, acc := range ynab.accounts {
if acc.Name == name {
return &acc, nil
}
}
account, err := ynab.queries.CreateAccount(context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
if err != nil {
return nil, err
}
ynab.accounts = append(ynab.accounts, account)
return &account, nil
}
func (ynab *YNABImport) GetPayee(context context.Context, name string) (uuid.NullUUID, error) {
if name == "" {
return uuid.NullUUID{}, nil
}
for _, pay := range ynab.payees {
if pay.Name == name {
return uuid.NullUUID{UUID: pay.ID, Valid: true}, nil
}
}
payee, err := ynab.queries.CreatePayee(context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
if err != nil {
return uuid.NullUUID{}, err
}
ynab.payees = append(ynab.payees, payee)
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
}
func (ynab *YNABImport) GetCategory(context context.Context, group string, name string) (uuid.NullUUID, error) { //nolint
if group == "" || name == "" {
return uuid.NullUUID{}, nil
}
for _, category := range ynab.categories {
if category.Name == name && category.Group == group {
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
}
}
var categoryGroup CategoryGroup
for _, existingGroup := range ynab.categoryGroups {
if existingGroup.Name == group {
categoryGroup = existingGroup
}
}
if categoryGroup.Name == "" {
newGroup := CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID}
var err error
categoryGroup, err = ynab.queries.CreateCategoryGroup(context, newGroup)
if err != nil {
return uuid.NullUUID{}, err
}
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
}
newCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
category, err := ynab.queries.CreateCategory(context, newCategory)
if err != nil {
return uuid.NullUUID{}, err
}
getCategory := GetCategoriesRow{
ID: category.ID,
CategoryGroupID: category.CategoryGroupID,
Name: category.Name,
Group: categoryGroup.Name,
}
ynab.categories = append(ynab.categories, getCategory)
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
}
+37
View File
@@ -0,0 +1,37 @@
package server
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) transactionsForAccount(c *gin.Context) {
accountID := c.Param("accountid")
accountUUID, err := uuid.Parse(accountID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
account, err := h.Service.GetAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
transactions, err := h.Service.GetTransactionsForAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, TransactionsResponse{account, transactions})
}
type TransactionsResponse struct {
Account postgres.Account
Transactions []postgres.GetTransactionsForAccountRow
}
+86
View File
@@ -0,0 +1,86 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres"
txdb "github.com/DATA-DOG/go-txdb"
"github.com/gin-gonic/gin"
)
func init() { //nolint:gochecknoinits
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
}
func TestRegisterUser(t *testing.T) { //nolint:funlen
t.Parallel()
database, err := postgres.Connect("pgtx", "example")
if err != nil {
t.Errorf("could not connect to db: %s", err)
return
}
h := Handler{
Service: database,
TokenVerifier: &jwt.TokenVerifier{
Secret: "this_is_my_demo_secret_for_unit_tests",
},
CredentialsVerifier: &bcrypt.Verifier{},
}
recorder := httptest.NewRecorder()
context, engine := gin.CreateTestContext(recorder)
h.LoadRoutes(engine)
t.Run("RegisterUser", func(t *testing.T) {
t.Parallel()
context.Request, err = http.NewRequest(
http.MethodPost,
"/api/v1/user/register",
strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
if err != nil {
t.Errorf("error creating request: %s", err)
return
}
h.registerPost(context)
if recorder.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
}
var response LoginResponse
err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil {
t.Error(err.Error())
t.Error("Error registering")
}
if len(response.Token) == 0 {
t.Error("Did not get a token")
}
})
t.Run("GetTransactions", func(t *testing.T) {
t.Parallel()
context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
if recorder.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
}
var response TransactionsResponse
err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil {
t.Error(err.Error())
t.Error("Error retreiving list of transactions.")
}
if len(response.Transactions) == 0 {
t.Error("Did not get any transactions.")
}
})
}
+112
View File
@@ -0,0 +1,112 @@
package server
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/pressly/goose/v3"
)
func (h *Handler) clearDatabase(c *gin.Context) {
if err := goose.Reset(h.Service.DB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
if err := goose.Up(h.Service.DB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
}
func (h *Handler) deleteBudget(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
h.clearBudgetData(c, budgetUUID)
err = h.Service.DeleteBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) clearBudgetData(c *gin.Context, budgetUUID uuid.UUID) {
rows, err := h.Service.DeleteAllAssignments(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
fmt.Printf("Deleted %d assignments\n", rows)
rows, err = h.Service.DeleteAllTransactions(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
fmt.Printf("Deleted %d transactions\n", rows)
}
func (h *Handler) clearBudget(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
h.clearBudgetData(c, budgetUUID)
}
func (h *Handler) cleanNegativeBudget(c *gin.Context) {
/*budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}*/
/*min_date, err := h.Service.GetFirstActivity(c.Request.Context(), budgetUUID)
date := getFirstOfMonthTime(min_date)
for {
nextDate := date.AddDate(0, 1, 0)
params := postgres.GetCategoriesWithBalanceParams{
BudgetID: budgetUUID,
ToDate: nextDate,
FromDate: date,
}
categories, err := h.Service.GetCategoriesWithBalance(c.Request.Context(), params)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
for _, category := range categories {
available := category.Available.GetFloat64()
if available >= 0 {
continue
}
var negativeAvailable postgres.Numeric
negativeAvailable.Set(-available)
createAssignment := postgres.CreateAssignmentParams{
Date: nextDate.AddDate(0, 0, -1),
Amount: negativeAvailable,
CategoryID: category.ID,
}
h.Service.CreateAssignment(c.Request.Context(), createAssignment)
}
if nextDate.Before(time.Now()) {
date = nextDate
} else {
break
}
}*/
}
+53
View File
@@ -0,0 +1,53 @@
package server
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) autocompleteCategories(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
query := c.Request.URL.Query().Get("s")
searchParams := postgres.SearchCategoriesParams{
BudgetID: budgetUUID,
Search: "%" + query + "%",
}
categories, err := h.Service.SearchCategories(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, categories)
}
func (h *Handler) autocompletePayee(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
query := c.Request.URL.Query().Get("s")
searchParams := postgres.SearchPayeesParams{
BudgetID: budgetUUID,
Search: query + "%",
}
payees, err := h.Service.SearchPayees(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, payees)
}
+33
View File
@@ -0,0 +1,33 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
type newBudgetInformation struct {
Name string `json:"name"`
}
func (h *Handler) newBudget(c *gin.Context) {
var newBudget newBudgetInformation
if err := c.BindJSON(&newBudget); err != nil {
c.AbortWithError(http.StatusNotAcceptable, err)
return
}
if newBudget.Name == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budget name is required"})
return
}
userID := MustGetToken(c).GetID()
budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, budget)
}
+231
View File
@@ -0,0 +1,231 @@
package server
import (
"fmt"
"net/http"
"strconv"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
}
func getFirstOfMonthTime(date time.Time) time.Time {
var monthM time.Month
year, monthM, _ := date.Date()
month := int(monthM)
return getFirstOfMonth(year, month, date.Location())
}
type CategoryWithBalance struct {
*postgres.GetCategoriesRow
Available postgres.Numeric
AvailableLastMonth postgres.Numeric
Activity postgres.Numeric
Assigned postgres.Numeric
}
func getDate(c *gin.Context) (time.Time, error) {
var year, month int
yearString := c.Param("year")
monthString := c.Param("month")
if yearString == "" && monthString == "" {
return getFirstOfMonthTime(time.Now()), nil
}
year, err := strconv.Atoi(yearString)
if err != nil {
return time.Time{}, fmt.Errorf("parse year: %w", err)
}
month, err = strconv.Atoi(monthString)
if err != nil {
return time.Time{}, fmt.Errorf("parse month: %w", err)
}
return getFirstOfMonth(year, month, time.Now().Location()), nil
}
func (h *Handler) budgetingForMonth(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
firstOfMonth, err := getDate(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{fmt.Sprintf("error loading balances: %s", err)})
return
}
categoriesWithBalance, moneyUsed := h.calculateBalances(
budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
availableBalance := h.getAvailableBalance(categories, budget, moneyUsed, cumultativeBalances, firstOfNextMonth)
data := struct {
Categories []CategoryWithBalance
AvailableBalance postgres.Numeric
}{categoriesWithBalance, availableBalance}
c.JSON(http.StatusOK, data)
}
func (*Handler) getAvailableBalance(categories []postgres.GetCategoriesRow, budget postgres.Budget,
moneyUsed postgres.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
firstOfNextMonth time.Time) postgres.Numeric {
availableBalance := postgres.NewZeroNumeric()
for _, cat := range categories {
if cat.ID != budget.IncomeCategoryID {
continue
}
availableBalance = moneyUsed
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
continue
}
if !bal.Date.Before(firstOfNextMonth) {
continue
}
availableBalance = availableBalance.Add(bal.Transactions)
}
}
return availableBalance
}
func (h *Handler) budgeting(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
data := struct {
Accounts []postgres.GetAccountsWithBalanceRow
Budget postgres.Budget
}{accounts, budget}
c.JSON(http.StatusOK, data)
}
func (h *Handler) calculateBalances(budget postgres.Budget,
firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow,
cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, postgres.Numeric) {
categoriesWithBalance := []CategoryWithBalance{}
hiddenCategory := CategoryWithBalance{
GetCategoriesRow: &postgres.GetCategoriesRow{
Name: "",
Group: "Hidden Categories",
},
Available: postgres.NewZeroNumeric(),
AvailableLastMonth: postgres.NewZeroNumeric(),
Activity: postgres.NewZeroNumeric(),
Assigned: postgres.NewZeroNumeric(),
}
moneyUsed := postgres.NewZeroNumeric()
for i := range categories {
cat := &categories[i]
// do not show hidden categories
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
firstOfNextMonth, &moneyUsed, firstOfMonth, hiddenCategory, budget)
if cat.Group == "Hidden Categories" {
hiddenCategory.Available = hiddenCategory.Available.Add(categoryWithBalance.Available)
hiddenCategory.AvailableLastMonth = hiddenCategory.AvailableLastMonth.Add(categoryWithBalance.AvailableLastMonth)
hiddenCategory.Activity = hiddenCategory.Activity.Add(categoryWithBalance.Activity)
hiddenCategory.Assigned = hiddenCategory.Assigned.Add(categoryWithBalance.Assigned)
continue
}
if cat.ID == budget.IncomeCategoryID {
continue
}
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
}
categoriesWithBalance = append(categoriesWithBalance, hiddenCategory)
return categoriesWithBalance, moneyUsed
}
func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
cumultativeBalances []postgres.GetCumultativeBalancesRow, firstOfNextMonth time.Time,
moneyUsed *postgres.Numeric, firstOfMonth time.Time, hiddenCategory CategoryWithBalance,
budget postgres.Budget) CategoryWithBalance {
categoryWithBalance := CategoryWithBalance{
GetCategoriesRow: cat,
Available: postgres.NewZeroNumeric(),
AvailableLastMonth: postgres.NewZeroNumeric(),
Activity: postgres.NewZeroNumeric(),
Assigned: postgres.NewZeroNumeric(),
}
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
continue
}
// skip everything in the future
if !bal.Date.Before(firstOfNextMonth) {
continue
}
*moneyUsed = moneyUsed.Sub(bal.Assignments)
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments)
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions)
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
*moneyUsed = moneyUsed.Add(categoryWithBalance.Available)
categoryWithBalance.Available = postgres.NewZeroNumeric()
}
if bal.Date.Before(firstOfMonth) {
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
} else if bal.Date.Before(firstOfNextMonth) {
categoryWithBalance.Activity = bal.Transactions
categoryWithBalance.Assigned = bal.Assignments
}
}
return categoryWithBalance
}
+4 -5
View File
@@ -1,16 +1,15 @@
package http
package server
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
)
func (h *Handler) dashboard(c *gin.Context) {
userID := c.MustGet("token").(budgeteer.Token).GetID()
budgets, err := h.Service.BudgetsForUser(userID)
userID := MustGetToken(c).GetID()
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
if err != nil {
return
}
@@ -18,7 +17,7 @@ func (h *Handler) dashboard(c *gin.Context) {
d := DashboardData{
Budgets: budgets,
}
c.HTML(http.StatusOK, "dashboard.html", d)
c.JSON(http.StatusOK, d)
}
type DashboardData struct {
+120
View File
@@ -0,0 +1,120 @@
package server
import (
"errors"
"io"
"io/fs"
"net/http"
"path"
"strings"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
)
// Handler handles incoming requests.
type Handler struct {
Service *postgres.Database
TokenVerifier budgeteer.TokenVerifier
CredentialsVerifier *bcrypt.Verifier
StaticFS http.FileSystem
}
// Serve starts the http server.
func (h *Handler) Serve() {
router := gin.Default()
h.LoadRoutes(router)
if err := router.Run(":1323"); err != nil {
panic(err)
}
}
type ErrorResponse struct {
Message string
}
// LoadRoutes initializes all the routes.
func (h *Handler) LoadRoutes(router *gin.Engine) {
router.Use(enableCachingForStaticFiles())
router.NoRoute(h.ServeStatic)
withLogin := router.Group("")
withLogin.Use(h.verifyLoginWithRedirect)
withBudget := router.Group("")
withBudget.Use(h.verifyLoginWithForbidden)
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
api := router.Group("/api/v1")
unauthenticated := api.Group("/user")
unauthenticated.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
unauthenticated.POST("/login", h.loginPost)
unauthenticated.POST("/register", h.registerPost)
authenticated := api.Group("")
authenticated.Use(h.verifyLoginWithForbidden)
authenticated.GET("/dashboard", h.dashboard)
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
authenticated.GET("/admin/clear-database", h.clearDatabase)
authenticated.GET("/budget/:budgetid", h.budgeting)
authenticated.GET("/budget/:budgetid/:year/:month", h.budgetingForMonth)
authenticated.GET("/budget/:budgetid/autocomplete/payees", h.autocompletePayee)
authenticated.GET("/budget/:budgetid/autocomplete/categories", h.autocompleteCategories)
authenticated.DELETE("/budget/:budgetid", h.deleteBudget)
authenticated.POST("/budget/:budgetid/import/ynab", h.importYNAB)
authenticated.POST("/budget/:budgetid/settings/clear", h.clearBudget)
budget := authenticated.Group("/budget")
budget.POST("/new", h.newBudget)
transaction := authenticated.Group("/transaction")
transaction.POST("/new", h.newTransaction)
transaction.POST("/:transactionid", h.newTransaction)
}
func (h *Handler) ServeStatic(c *gin.Context) {
h.ServeStaticFile(c, c.Request.URL.Path)
}
func (h *Handler) ServeStaticFile(c *gin.Context, fullPath string) {
file, err := h.StaticFS.Open(fullPath)
if errors.Is(err, fs.ErrNotExist) {
h.ServeStaticFile(c, path.Join("/", "/index.html"))
return
}
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
stat, err := file.Stat()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if stat.IsDir() {
h.ServeStaticFile(c, path.Join(fullPath, "index.html"))
return
}
if file, ok := file.(io.ReadSeeker); ok {
http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file)
} else {
panic("File does not implement ReadSeeker")
}
}
func enableCachingForStaticFiles() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
c.Header("Cache-Control", "max-age=86400")
}
}
}
+37
View File
@@ -0,0 +1,37 @@
package server
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type JSONDate time.Time
// UnmarshalJSON parses the JSONDate from a JSON input.
func (j *JSONDate) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"")
t, err := time.Parse("2006-01-02", s)
if err != nil {
return fmt.Errorf("parse date: %w", err)
}
*j = JSONDate(t)
return nil
}
// MarshalJSON converts the JSONDate to a JSON in ISO format.
func (j JSONDate) MarshalJSON() ([]byte, error) {
result, err := json.Marshal(time.Time(j))
if err != nil {
return nil, fmt.Errorf("marshal date: %w", err)
}
return result, nil
}
// Format formats the time using the regular time.Time mechanics..
func (j JSONDate) Format(s string) string {
t := time.Time(j)
return t.Format(s)
}
+173
View File
@@ -0,0 +1,173 @@
package server
import (
"context"
"fmt"
"net/http"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const (
HeaderName = "Authorization"
Bearer = "Bearer "
ParamName = "token"
)
func MustGetToken(c *gin.Context) budgeteer.Token { //nolint:ireturn
token := c.MustGet(ParamName)
if token, ok := token.(budgeteer.Token); !ok {
return token
}
panic("Token is not a valid Token")
}
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, *ErrorResponse) { //nolint:ireturn
tokenString := c.GetHeader(HeaderName)
if len(tokenString) <= len(Bearer) {
return nil, &ErrorResponse{"no authorization header supplied"}
}
tokenString = tokenString[7:]
token, err := h.TokenVerifier.VerifyToken(tokenString)
if err != nil {
return nil, &ErrorResponse{fmt.Sprintf("verify token '%s': %s", tokenString, err)}
}
return token, nil
}
func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
// c.Header("WWW-Authenticate", "Bearer")
c.AbortWithStatusJSON(http.StatusForbidden, err)
return
}
c.Set(ParamName, token)
c.Next()
}
func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
c.Set(ParamName, token)
c.Next()
}
type loginInformation struct {
Password string `json:"password"`
User string `json:"user"`
}
func (h *Handler) loginPost(c *gin.Context) {
var login loginInformation
err := c.BindJSON(&login)
if err != nil {
return
}
user, err := h.Service.GetUserByUsername(c.Request.Context(), login.User)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
if err = h.CredentialsVerifier.Verify(login.Password, user.Password); err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
token, err := h.TokenVerifier.CreateToken(&user)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
}
go h.UpdateLastLogin(user.ID)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil {
return
}
c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
}
type LoginResponse struct {
Token string
User postgres.User
Budgets []postgres.Budget
}
type registerInformation struct {
Password string `json:"password"`
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) registerPost(c *gin.Context) {
var register registerInformation
err := c.BindJSON(&register)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"error parsing body"})
return
}
if register.Email == "" || register.Password == "" || register.Name == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"e-mail, password and name are required"})
return
}
_, err = h.Service.GetUserByUsername(c.Request.Context(), register.Email)
if err == nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"email is already taken"})
return
}
hash, err := h.CredentialsVerifier.Hash(register.Password)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
createUser := postgres.CreateUserParams{
Name: register.Name,
Password: hash,
Email: register.Email,
}
user, err := h.Service.CreateUser(c.Request.Context(), createUser)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
token, err := h.TokenVerifier.CreateToken(&user)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
}
go h.UpdateLastLogin(user.ID)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil {
return
}
c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
}
func (h *Handler) UpdateLastLogin(userID uuid.UUID) {
_, err := h.Service.UpdateLastLogin(context.Background(), userID)
if err != nil {
fmt.Printf("Error updating last login: %s", err)
}
}
+78
View File
@@ -0,0 +1,78 @@
package server
import (
"fmt"
"net/http"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NewTransactionPayload struct {
Date JSONDate `json:"date"`
Payee struct {
ID uuid.NullUUID
Name string
} `json:"payee"`
Category struct {
ID uuid.NullUUID
Name string
} `json:"category"`
Memo string `json:"memo"`
Amount string `json:"amount"`
BudgetID uuid.UUID `json:"budgetId"`
AccountID uuid.UUID `json:"accountId"`
State string `json:"state"`
}
func (h *Handler) newTransaction(c *gin.Context) {
var payload NewTransactionPayload
err := c.BindJSON(&payload)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
amount := postgres.Numeric{}
err = amount.Set(payload.Amount)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("amount: %w", err))
return
}
payeeID := payload.Payee.ID
if !payeeID.Valid && payload.Payee.Name != "" {
newPayee := postgres.CreatePayeeParams{
Name: payload.Payee.Name,
BudgetID: payload.BudgetID,
}
payee, err := h.Service.CreatePayee(c.Request.Context(), newPayee)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create payee: %w", err))
}
payeeID = uuid.NullUUID{
UUID: payee.ID,
Valid: true,
}
}
newTransaction := postgres.CreateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
AccountID: payload.AccountID,
PayeeID: payeeID,
CategoryID: payload.Category.ID,
Status: postgres.TransactionStatus(payload.State),
}
transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
return
}
c.JSON(http.StatusOK, transaction)
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) importYNAB(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactionsFile, err := c.FormFile("transactions")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactions, err := transactionsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportTransactions(c.Request.Context(), transactions)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignmentsFile, err := c.FormFile("assignments")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignments, err := assignmentsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportAssignments(c.Request.Context(), assignments)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"github.com/google/uuid"
)
// Token contains data that authenticates a user
// Token contains data that authenticates a user.
type Token interface {
GetUsername() string
GetName() string
@@ -13,7 +13,7 @@ type Token interface {
GetID() uuid.UUID
}
// TokenVerifier verifies a Token
// TokenVerifier verifies a Token.
type TokenVerifier interface {
VerifyToken(string) (Token, error)
CreateToken(*postgres.User) (string, error)
-36
View File
@@ -1,36 +0,0 @@
{{template "base" .}}
{{define "title"}}{{.Account.Name}}{{end}}
{{define "new"}}
{{template "transaction-new"}}
{{end}}
{{define "main"}}
<div class="budget-item">
<a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
<span class="time"></span>
</div>
<table class="container col-lg-12" id="content">
{{range .Transactions}}
<tr>
<td>{{.Date}}</td>
<td>
{{.Account}}
</td>
<td>
{{.Payee}}
</td>
<td>
{{if .CategoryGroup}}
{{.CategoryGroup}} : {{.Category}}
{{end}}
</td>
<td>
<a href="transaction/{{.ID}}">{{.Memo}}</a>
</td>
{{template "amount-cell" .Amount}}
</tr>
{{end}}
</table>
{{end}}
-17
View File
@@ -1,17 +0,0 @@
{{define "title"}}
Accounts
{{end}}
{{define "new"}}
{{end}}
{{template "base" .}}
{{define "main"}}
{{range .Accounts}}
<div class="budget-item">
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
<span class="time">{{printf "%.2f" .Balance.GetFloat64}}</span>
</div>
{{end}}
{{end}}
-18
View File
@@ -1,18 +0,0 @@
{{define "title"}}
Admin
{{end}}
{{define "sidebar"}}
Settings for all Budgets
{{end}}
{{template "base" .}}
{{define "main"}}
<h1>Danger Zone</h1>
<div class="budget-item">
<button>Clear database</button>
<p>This removes all data and starts from scratch. Not undoable!</p>
</div>
{{end}}
-11
View File
@@ -1,11 +0,0 @@
{{define "amount"}}
<span class="right {{if .GetPositive}}{{else}}negative{{end}}">
{{printf "%.2f" .GetFloat64}}
</span>
{{end}}
{{define "amount-cell"}}
<td class="right {{if .GetPositive}}{{else}}negative{{end}}">
{{printf "%.2f" .GetFloat64}}
</td>
{{end}}
-38
View File
@@ -1,38 +0,0 @@
{{define "base"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link href="/static/css/bootstrap.min.css" rel="stylesheet" />
<link href="/static/css/bootstrap-theme.min.css" rel="stylesheet" />
<link href="/static/css/main.css" rel="stylesheet" />
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://malsup.github.io/jquery.form.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<script src="/static/js/main.js"></script>
<title>{{template "title" .}} - Budgeteer</title>
{{block "more-head" .}}{{end}}
</head>
<body>
<div id="sidebar">
{{block "sidebar" .}}
{{template "budget-sidebar" .}}
{{end}}
</div>
<div id="content">
<div class="container" id="head">
{{template "title" .}}
</div>
<div class="container col-lg-12" id="content">
{{template "main" .}}
</div>
{{block "new" .}}{{end}}
</div>
</body>
</html>
{{end}}
-40
View File
@@ -1,40 +0,0 @@
{{define "budget-new"}}
<div id="newbudgetmodal" class="modal fade">
<div class="modal-dialog" role="document">
<script>
$(document).ready(function () {
$('#errorcreatingbudget').hide();
$('#newbudgetform').ajaxForm({
error: function() {
$('#errorcreatingbudget').show();
}
});
});
</script>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Budget</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form id="newbudgetform" action="/api/v1/budget/new" method="POST">
<div class="modal-body">
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" class="form-control" placeholder="Name" />
</div>
<div id="errorcreatingbudget">
Error creating budget.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary" value="Create" class="form-control" />
</div>
</form>
</div>
</div>
</div>
{{end}}
-33
View File
@@ -1,33 +0,0 @@
{{define "budget-sidebar"}}
<ul>
<li><a href="/budget/{{.Budget.ID}}">Budget</a></li>
<li>Reports (Coming Soon)</li>
<li><a href="/budget/{{.Budget.ID}}/all-accounts">All Accounts</a></li>
<li>
On-Budget Accounts
<ul class="two-valued">
{{range .Accounts}}
<li>
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
{{template "amount" .Balance}}
</li>
{{end}}
<li>
<a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
</li>
</ul>
</li>
<li>
Off-Budget Accounts
</li>
<li>
Closed Accounts
</li>
<li>
+ Add Account
</li>
<li>
<a href="/admin">Settings</a>
</li>
</ul>
{{end}}
-31
View File
@@ -1,31 +0,0 @@
{{template "base" .}}
{{define "title"}}Budget{{end}}
{{define "new"}}
{{template "transaction-new"}}
{{end}}
{{define "main"}}
<div class="budget-item">
<a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
<span class="time"></span>
</div>
<table class="container col-lg-12" id="content">
{{range .Transactions}}
<tr>
<td>{{.Date}}</td>
<td>
{{.Account}}
</td>
<td>
{{.Payee}}
</td>
<td>
<a href="transaction/{{.ID}}">{{.Memo}}</a>
</td>
{{template "amount-cell" .Amount}}
</tr>
{{end}}
</table>
{{end}}
-44
View File
@@ -1,44 +0,0 @@
{{template "base" .}}
{{define "title"}}
{{printf "Budget for %s %d" .Date.Month .Date.Year}}
{{end}}
{{define "new"}}
{{template "transaction-new"}}
{{end}}
{{define "main"}}
<div class="budget-item">
<a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
<span class="time"></span>
</div>
<div>
<a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Previous.Year .Previous.Month}}">Previous Month</a> -
<a href="{{printf "/budget/%s" .Budget.ID}}">Current Month</a> -
<a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Next.Year .Next.Month}}">Next Month</a>
</div>
<table class="container col-lg-12" id="content">
<tr>
<th>Group</th>
<th>Category</th>
<th></th>
<th></th>
<th>Balance</th>
<th>Activity</th>
</tr>
{{range .Categories}}
<tr>
<td>{{.Group}}</td>
<td>{{.Name}}</td>
<td>
</td>
<td>
</td>
{{template "amount-cell" .Balance}}
{{template "amount-cell" .Activity}}
</tr>
{{end}}
</table>
{{end}}
-26
View File
@@ -1,26 +0,0 @@
{{define "title"}}
Budgets
{{end}}
{{define "new"}}
{{template "budget-new"}}
{{end}}
{{define "sidebar"}}
Please select a budget.
{{end}}
{{template "base" .}}
{{define "main"}}
{{range .Budgets}}
<div class="budget-item">
<a href="budget/{{.ID}}">{{.Name}}</a>
<span class="time"></span>
</div>
{{end}}
<div class="budget-item">
<a href="#newbudgetmodal" data-toggle="modal" data-target="#newbudgetmodal">New Budget</a>
<span class="time"></span>
</div>
{{end}}
+13 -17
View File
@@ -1,17 +1,13 @@
{{define "title"}}
Start
{{end}}
{{define "new"}}
{{end}}
{{template "base" .}}
{{define "main"}}
<div class="container col-md-8 col-ld-8" id="content">
Willkommen bei Budgeteer, der neuen App für's Budget!
</div>
<div class="container col-md-4" id="login">
<a href="/login">Login</a> or <a href="/login">register</a>
</div>
{{end}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-42
View File
@@ -1,42 +0,0 @@
{{template "base" .}}
{{define "title"}}Login{{end}}
{{define "more-head"}}
<script>
$(document).ready(function() {
$('#invalidCredentials').hide();
$('#loginForm').ajaxForm({
success: function() {
window.location.href = "/dashboard";
},
error: function() {
$('#invalidCredentials').show();
}
});
});
</script>
{{end}}
{{define "main"}}
<form id="loginForm" action="/api/v1/user/login" method="POST" class="center-block">
<div class="form-group">
<label for="username">User</label>
<input type="text" name="username" class="form-control" placeholder="User" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control" placeholder="Password" />
<p id="invalidCredentials">
The entered credentials are invalid
</p>
</div>
<input type="submit" value="Login" class="btn btn-default" />
<p>
New user? <a href="/register">Register</a> instead!
</p>
</form>
{{end}}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"serve": "vite preview",
"build": "vite build",
"dev": "vite",
"preview": "vite preview"
},
"dependencies": {
"@mdi/font": "5.9.55",
"@vueuse/core": "^7.6.1",
"autoprefixer": "^10.4.2",
"pinia": "^2.0.11",
"postcss": "^8.4.6",
"tailwindcss": "^3.0.18",
"vue": "^3.2.25",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.0.0",
"@vue/cli-plugin-babel": "5.0.0-beta.7",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-service": "5.0.0-beta.7",
"sass": "^1.38.0",
"sass-loader": "^10.0.0",
"vite": "^2.7.2",
"vue-cli-plugin-vuetify": "~2.4.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

-72
View File
@@ -1,72 +0,0 @@
{{define "title"}}Register{{end}}
{{template "base" .}}
{{define "more-head"}}
<script>
function checkPasswordMatchUi() {
if(checkPasswordMatch())
$("#divCheckPasswordMatch").html("Passwords match.");
else
$("#divCheckPasswordMatch").html("Passwords do not match!");
}
function checkPasswordMatch() {
var password = $("#password").val();
var confirmPassword = $("#password_confirm").val();
return password == confirmPassword;
}
$(document).ready(function () {
$("#password, #password_confirm").keyup(checkPasswordMatchUi);
$('#invalidCredentials').hide();
$('#loginForm').ajaxForm({
beforeSubmit: function(a, b, c) {
var match = checkPasswordMatch();
if(!match){
$("#divCheckPasswordMatch").fadeOut(300).fadeIn(300).fadeOut(300).fadeIn(300);
}
return match;
},
success: function() {
window.location.href = "/dashboard";
},
error: function() {
$('#invalidCredentials').show();
}
});
});
</script>
{{end}}
{{define "main"}}
<form id="loginForm" action="/api/v1/user/register" method="POST" class="center-block">
<div class="form-group">
<label for="email">E-Mail</label>
<input type="text" name="email" class="form-control" placeholder="E-Mail" />
</div>
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" class="form-control" placeholder="Name" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" id="password" class="form-control" placeholder="Password" />
<input type="password" id="password_confirm" class="form-control" placeholder="Verify password" />
</div>
<div id="divCheckPasswordMatch"></div>
<div id="invalidCredentials">
Username already exists
</div>
<input type="submit" value="Login" class="form-control" />
<p>
Existing user? <a href="/login">Login</a> instead!
</p>
</form>
{{end}}
+5
View File
@@ -0,0 +1,5 @@
declare module "*.vue" {
import { defineComponent } from "vue";
const component: ReturnType<typeof defineComponent>;
export default component;
}
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import { mapState } from "pinia";
import { defineComponent } from "vue";
import { useBudgetsStore } from "./stores/budget";
import { useSessionStore } from "./stores/session";
import { useSettingsStore } from "./stores/settings";
export default defineComponent({
computed: {
...mapState(useBudgetsStore, ["CurrentBudgetName"]),
...mapState(useSettingsStore, ["Menu"]),
...mapState(useSessionStore, ["LoggedIn"]),
},
methods: {
logout() {
useSessionStore().logout();
this.$router.push("/login");
},
toggleMenu() {
useSettingsStore().toggleMenu();
},
toggleMenuSize() {
useSettingsStore().toggleMenuSize();
}
},
})
</script>
<template>
<div class="box-border w-full">
<div class="flex bg-gray-400 p-4 m-2 rounded-lg">
<span class="flex-1 font-bold text-5xl -my-3 hidden md:inline" @click="toggleMenuSize"></span>
<span class="flex-1 font-bold text-5xl -my-3 md:hidden" @click="toggleMenu"></span>
<span class="flex-1">{{ CurrentBudgetName }}</span>
<div class="flex flex-1 flex-row justify-end -mx-4">
<router-link class="mx-4" v-if="LoggedIn" to="/dashboard">Dashboard</router-link>
<router-link class="mx-4" v-if="!LoggedIn" to="/login">Login</router-link>
<a class="mx-4" v-if="LoggedIn" @click="logout">Logout</a>
</div>
</div>
<div class="flex flex-col md:flex-row flex-1">
<div
:class="[Menu.Expand ? 'md:w-72' : 'md:w-36', Menu.Show ? '' : 'hidden']"
class="md:block flex-shrink-0 w-full"
>
<router-view name="sidebar"></router-view>
</div>
<div class="flex-1 p-6">
<router-view></router-view>
</div>
</div>
</div>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
import { useSessionStore } from "./stores/session";
export const BASE_URL = "/api/v1"
export function GET(path: string) {
const sessionStore = useSessionStore();
return fetch(BASE_URL + path, {
headers: sessionStore.AuthHeaders,
})
};
export function POST(path: string, body: FormData | string | null) {
const sessionStore = useSessionStore();
return fetch(BASE_URL + path, {
method: "POST",
headers: sessionStore.AuthHeaders,
body: body,
})
}
export function DELETE(path: string) {
const sessionStore = useSessionStore();
return fetch(BASE_URL + path, {
method: "DELETE",
headers: sessionStore.AuthHeaders,
})
}
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts" setup>
import { defineComponent, PropType, ref, watch } from "vue"
import { GET } from "../api";
import { useBudgetsStore } from "../stores/budget";
export interface Suggestion {
ID: string
Name: string
}
interface Data {
Selected: Suggestion | undefined
SearchQuery: String
Suggestions: Suggestion[]
}
const props = defineProps<{
modelValue: Suggestion | undefined,
type: String
}>();
const Selected = ref<Suggestion | undefined>(props.modelValue || undefined);
const SearchQuery = ref(props.modelValue?.Name || "");
const Suggestions = ref<Array<Suggestion>>([]);
const emit = defineEmits(["update:modelValue"]);
watch(SearchQuery, () => {
load(SearchQuery.value);
});
function saveTransaction(e: MouseEvent) {
e.preventDefault();
};
function load(text: String) {
emit('update:modelValue', { ID: null, Name: text });
if (text == "") {
Suggestions.value = [];
return;
}
const budgetStore = useBudgetsStore();
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.type + "?s=" + text)
.then(x => x.json())
.then(x => {
let suggestions = x || [];
if (suggestions.length > 10) {
suggestions = suggestions.slice(0, 10);
}
Suggestions.value = suggestions;
});
};
function keypress(e: KeyboardEvent) {
console.log(e.key);
if (e.key == "Enter") {
const selected = Suggestions.value[0];
selectElement(selected);
const el = (<HTMLInputElement>e.target);
const inputElements = Array.from(el.ownerDocument.querySelectorAll('input:not([disabled]):not([readonly])'));
const currentIndex = inputElements.indexOf(el);
const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0];
(<HTMLInputElement>nextElement).focus();
}
};
function selectElement(element: Suggestion) {
Selected.value = element;
Suggestions.value = [];
emit('update:modelValue', element);
};
function select(e: MouseEvent) {
const target = (<HTMLInputElement>e.target);
const valueAttribute = target.attributes.getNamedItem("value");
let selectedID = "";
if (valueAttribute != null)
selectedID = valueAttribute.value;
const selected = Suggestions.value.filter(x => x.ID == selectedID)[0];
selectElement(selected);
};
function clear() {
Selected.value = undefined;
emit('update:modelValue', { ID: null, Name: SearchQuery.value });
};
</script>
<template>
<div>
<input
class="border-b-2 border-black"
@keypress="keypress"
v-if="Selected == undefined"
v-model="SearchQuery"
/>
<span @click="clear" v-if="Selected != undefined" class="bg-gray-300">{{ Selected.Name }}</span>
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2">
<span
v-for="suggestion in Suggestions"
class="block"
@click="select"
:value="suggestion.ID"
>{{ suggestion.Name }}</span>
</div>
</div>
</template>
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts" setup>
</script>
<template>
<div class="flex flex-row items-center bg-gray-300 h-32 rounded-lg">
<slot></slot>
</div>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{ value: number | undefined }>();
const internalValue = computed(() => Number(props.value ?? 0));
const formattedValue = computed(() => internalValue.value.toLocaleString(undefined, {
minimumFractionDigits: 2,
}));
</script>
<template>
<span class="text-right" :class="internalValue < 0 ? 'negative' : ''">{{ formattedValue }} </span>
</template>
@@ -0,0 +1,61 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
import { useAccountStore } from '../stores/budget-account'
const props = defineProps<{
budgetid: string
accountid: string
}>()
const TransactionDate = ref(new Date().toISOString().substring(0, 10));
const Payee = ref<Suggestion | undefined>(undefined);
const Category = ref<Suggestion | undefined>(undefined);
const Memo = ref("");
const Amount = ref("0");
const payload = computed(() => JSON.stringify({
budget_id: props.budgetid,
account_id: props.accountid,
date: TransactionDate.value,
payee: Payee.value,
category: Category.value,
memo: Memo.value,
amount: Amount.value,
state: "Uncleared"
}));
const accountStore = useAccountStore();
function saveTransaction(e: MouseEvent) {
e.preventDefault();
accountStore.saveTransaction(payload.value);
}
</script>
<template>
<tr>
<td style="width: 90px;" class="text-sm">
<input class="border-b-2 border-black" type="date" v-model="TransactionDate" />
</td>
<td style="max-width: 150px;">
<Autocomplete v-model="Payee" type="payees" />
</td>
<td style="max-width: 200px;">
<Autocomplete v-model="Category" type="categories" />
</td>
<td>
<input class="block w-full border-b-2 border-black" type="text" v-model="Memo" />
</td>
<td style="width: 80px;" class="text-right">
<input
class="text-right block w-full border-b-2 border-black"
type="currency"
v-model="Amount"
/>
</td>
<td style="width: 20px;">
<input type="submit" @click="saveTransaction" value="Save" />
</td>
<td style="width: 20px;"></td>
</tr>
</template>

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