301 Commits

Author SHA1 Message Date
1a79177422 Fix missing computed calls in BudgetSidebar
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-14 22:49:30 +00:00
0aa877d7d4 Merge pull request 'Use vue's Composition API' (#10) from vue-composition into master
All checks were successful
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
87a70ee5fa Revert
All checks were successful
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
0a030eaee1 Convert other pages to composition API 2022-02-14 22:24:42 +00:00
d11c0036b5 Do not use a store for API 2022-02-14 08:12:41 +00:00
ca93e9cd55 Migrate Account.vue to composition API
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-14 08:06:16 +00:00
a061ffd350 Merge pull request 'Implement minor fixes' (#9) from minor-fixes into master
All checks were successful
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
5633c029ac Update Earthfile and production docker-compose.yml
All checks were successful
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
a97d050ead Specify Map serializer for budgets
All checks were successful
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
958929fd16 Update docker-compose to use new tag 2022-02-13 13:16:50 +00:00
a61d80ee1f Implement SPA handling in Backend 2022-02-13 13:16:50 +00:00
41c5095b8b Merge pull request 'Use woodpecker for CI' (#8) from woodpecker into master
All checks were successful
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
c074dfe865 Fix Taskfile
All checks were successful
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
fa8a2854f2 Add node packages to image
All checks were successful
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
15bb73de30 Add secrects
All checks were successful
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
e506510fde Remove user and add go deps from builder image 2022-02-11 23:49:43 +00:00
11ac8758da Disable docker from taskfile
Some checks failed
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
3db5e1e72c Reenable docker push
Some checks failed
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
4e2a783b2e Extract variable 2022-02-11 23:40:46 +00:00
bb83563bc6 Use docker build-target
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:35:15 +00:00
0a21c59eff Fetch deps before build
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:33:47 +00:00
3308b58524 Fix sources in Taskfile
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:30:53 +00:00
941b642f39 Build docker within task
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:16:57 +00:00
6a77c71df4 Always pull dev image
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:59:38 +00:00
bf20914c1c Use default build name
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:58:31 +00:00
7874ef69a2 Fix Taskfile
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:57:26 +00:00
2e719b590e Add admin page 2022-02-11 22:52:00 +00:00
95d8e4fccc Rewrite event filter
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:49:06 +00:00
7cf106eb85 Rename 2022-02-11 22:41:35 +00:00
148fc18cd8 Fix schema 2022-02-11 22:41:07 +00:00
47095ae6ec Add .woodpecker.yaml 2022-02-11 22:39:19 +00:00
200fa1835a Merge pull request 'Replace vuex by pinia' (#7) from pinia into master
Reviewed-on: #7
2022-02-11 23:20:06 +01:00
cd0b370b50 Fix typescript errors 2022-02-11 22:18:00 +00:00
1af69b047d Fix mapping for menu in App.vue 2022-02-11 22:03:37 +00:00
f6cb6d4163 Do not use useStorage for root state 2022-02-11 22:03:26 +00:00
45389e01be Use vueuse useStorage instead of manually using localStorage 2022-02-11 21:38:06 +00:00
5868c3310e Fix Map.values() access and convert to array 2022-02-10 18:07:52 +00:00
1dc818f51f Add pinia-logger 2022-02-10 16:30:53 +00:00
0e342f2bcf Try to move perstisting to stores 2022-02-10 16:30:42 +00:00
21dcd7837b Bugfixes 2022-02-10 16:07:29 +00:00
c693625e34 Remove vuex 2022-02-10 15:49:30 +00:00
8b0e368d58 Fix return type of AuthHeaders 2022-02-10 13:35:51 +00:00
0c094d6f6b And on 2022-02-09 23:16:16 +00:00
9b8ae7a44d And on… 2022-02-09 23:14:48 +00:00
08330ce33c Migrate farther 2022-02-09 23:12:26 +00:00
2d0737a10c First step from vuex to pinia 2022-02-09 23:08:43 +00:00
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
6ab8a96888 Implement first db-test using go-txdb 2022-02-09 22:40:35 +00:00
c3db535e10 go get go-txdb 2022-02-09 22:40:35 +00:00
5d292b2a31 Add first test 2022-02-09 22:40:35 +00:00
e934d407c2 Use simple connection string in config 2022-02-09 22:40:35 +00:00
2c71c521f9 Add bordersr to inputs 2022-02-09 22:40:35 +00:00
3bd5845068 Focus next element on Enter 2022-02-09 22:40:35 +00:00
c3a1564c4e Extract LoadRoutes method 2022-02-09 22:40:35 +00:00
922041856f Extract struct for return value of getTransactionsForAccount 2022-02-09 22:40:35 +00:00
6cafaec422 Inline tv and bv vars 2022-02-09 22:40:35 +00:00
499d78a781 Merge pull request 'design-improvements' (#5) from design-improvements into master
Reviewed-on: #5
2022-02-09 23:40:27 +01:00
227c2a7564 Align amount input right 2022-02-09 22:36:21 +00:00
07cd7a7c2b Add table Header 2022-02-09 22:36:21 +00:00
bd1e1cbfb8 Move Transaction row to own component 2022-02-09 22:36:21 +00:00
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
4eeecc2bd9 Show transfer account if filled 2022-02-08 21:28:55 +00:00
378d764220 Merge pull request 'Convert frontend to Vue' (#3) from vue into master
Reviewed-on: #3
2022-02-08 22:20:11 +01:00
2559e40ebd Merge branch 'master' into vue 2022-02-08 21:19:47 +00:00
0579ec5106 Save and restore ExpandMenu state 2022-02-08 21:14:44 +00:00
33bdfaddc4 Move budgeting to own route 2022-02-08 21:14:36 +00:00
eb195dfd29 Redirect to current route from site without date 2022-02-08 11:13:55 +00:00
d2b414f328 Always treat Currency.value as number 2022-02-08 10:53:06 +00:00
e873795562 Show sum for all on/off-budget accounts 2022-02-07 22:38:46 +00:00
33c83c0a69 Shrink date input to match column width 2022-02-07 22:19:59 +00:00
2646c5d3b7 Use Currency component for Account header 2022-02-07 22:19:48 +00:00
3ef5836607 Implement simple keyboard selection 2022-02-07 22:19:24 +00:00
b5c657978c Alternate row colors for transactions 2022-02-07 16:39:06 +00:00
0136e3b978 Use currency component for transactions 2022-02-07 16:38:53 +00:00
427e7e5359 Extract value formatting to computed property 2022-02-07 16:38:26 +00:00
139b6ec636 Convert multiple class binding to array syntax 2022-02-07 16:38:08 +00:00
9dad1dabbd Implement new transaction 2022-02-07 16:17:14 +00:00
95fcb9a586 Split Menu between md and smaller devices 2022-02-07 16:07:54 +00:00
3c1d83d8a2 Fix negative values smaller than zero 2022-02-07 16:07:37 +00:00
f09a2a4ca7 Fix multiplication instead of division 2022-02-06 22:21:04 +00:00
487aa89f18 Use Numeric in JSON output 2022-02-06 22:12:48 +00:00
5763409aa8 Split store 2022-02-06 20:15:59 +00:00
5ca09d2825 Fix calling dispatch for mutation 2022-02-06 20:15:29 +00:00
e18e68c964 Comment not implemented routes 2022-02-06 20:15:14 +00:00
065159a817 Cleanup unused backend code and routes 2022-02-06 20:15:00 +00:00
68c2d3ff28 Fix typing issues in Settings 2022-02-05 15:00:26 +00:00
0f2db4985f Remove old plugins 2022-02-05 14:54:14 +00:00
f532ffc0c1 Rename routes.ts 2022-02-05 14:36:20 +00:00
4edd1a8bf1 Cleanup more deps 2022-02-05 14:36:01 +00:00
a7890bce16 Add vue shim 2022-02-05 14:27:20 +00:00
ddaf647d87 Add getters for current budget ID and Name 2022-02-05 14:26:56 +00:00
80b5a7abfe Small layout improvements 2022-02-05 13:48:31 +00:00
2204188600 Actually call API 2022-02-04 21:43:51 +00:00
3d1d1308ac Use JSON params instead of PostForm 2022-02-04 21:43:35 +00:00
d5904a7c4a Implement testing endpoint for new transaction 2022-02-04 21:43:03 +00:00
d9c03e231e Improve error when no auth supplied 2022-02-04 21:40:22 +00:00
dba1e8c276 Change type of modelValue 2022-02-04 21:18:24 +00:00
ebc2286116 Display name if selected 2022-02-04 21:14:02 +00:00
d647650142 Order payees by name 2022-02-04 21:13:40 +00:00
057c576831 Implement autocomplete for categories 2022-02-04 21:13:30 +00:00
056071c6e6 Make autocomplete usable for multiple types 2022-02-04 20:47:50 +00:00
0305aa86c1 Add Card component 2022-02-04 20:44:08 +00:00
d9aed7603e Implement custom autocomplete component 2022-02-04 20:43:57 +00:00
e18ef79839 Implement minimal autocomplete for Payees 2022-02-04 20:24:29 +00:00
b93e44930a Use hamburger menu icon instead of 'Home'-text 2022-02-04 20:01:26 +00:00
c8e9a8b375 Improve login page 2022-02-04 19:59:21 +00:00
1d0fe60ea4 Improve layout 2022-02-04 19:54:32 +00:00
3f7f646120 Extract card and use for budget list 2022-02-04 17:16:03 +00:00
dfa8f369f0 Make it work slightly ;-) 2022-02-04 16:56:17 +00:00
d825379a01 Try to use tailwind 2022-02-04 16:34:23 +00:00
f7dfc7b455 Initialize tailwindcss 2022-02-04 15:53:56 +00:00
2203542d90 Regenerate yarn.lock 2022-02-04 15:53:14 +00:00
72f9a6413f Remove vuetify 2022-02-04 15:51:50 +00:00
bf25922fc4 Add fuzzy module 2022-02-04 15:46:17 +00:00
46d727c650 Try to implement autocomplete for payees 2022-02-01 21:08:37 +00:00
b6628dd8cb Fallback to empty list if budgets or accounts are unset 2022-02-01 20:23:46 +00:00
132ae75755 Add index page 2022-02-01 20:23:32 +00:00
2b885ca189 Implement dummy row for new transaction 2022-02-01 20:23:19 +00:00
37a842b395 Add main.ts 2022-02-01 08:15:19 +00:00
9353d82648 Fix diverse errors 2022-01-31 21:45:19 +00:00
b350fe7d74 Handle some differences between js/ts 2022-01-31 21:37:24 +00:00
7cba471de7 Convert most code to ts 2022-01-31 21:14:13 +00:00
27508af3a7 Convert Register.vue to TS 2022-01-31 20:47:56 +00:00
e0981630ab Enable typescript support 2022-01-31 20:47:45 +00:00
a7178e39c9 Add vue CLI to dev image 2022-01-31 20:40:15 +00:00
29f0c51e35 Extract some types 2022-01-31 20:34:32 +00:00
9c0126b14c Format document 2022-01-31 19:58:06 +00:00
34b9c14419 Redirect to dashboard and remove budget on delete 2022-01-31 16:12:17 +00:00
8e2a62929e Fix ignoring return value 2022-01-31 16:03:56 +00:00
95b1ac5943 Implement Delete Budget 2022-01-31 15:50:13 +00:00
1be3b6930d Install go packages as non-root in Dockerfile 2022-01-31 15:49:49 +00:00
e5fe3d06c4 Remember menu state 2022-01-31 14:43:49 +00:00
136f15badc Move showMenu to store 2022-01-31 14:43:20 +00:00
f091ce8945 Handle LOGIN via action 2022-01-29 23:32:11 +00:00
24370c9d32 Show negative colors in red 2022-01-29 23:29:17 +00:00
e8dbb54086 Merge stores and handle fetching from router 2022-01-29 23:26:57 +00:00
6f7aa28d22 Enable vuex logging 2022-01-29 22:39:13 +00:00
e85cf0ea55 Improve layout of Account view 2022-01-29 22:38:23 +00:00
58683a33fb Improve layout of Account 2022-01-28 10:46:56 +00:00
a45d02f9e0 Redesign app bar 2022-01-28 10:35:29 +00:00
0334b35041 Improve Layout of BudgetSettings 2022-01-28 09:44:30 +00:00
b52c4cb787 Make layout more flexible 2022-01-27 21:41:01 +00:00
0db6d6c67a Organize settings in cards 2022-01-27 21:37:04 +00:00
0b1f673c05 Extract action for API calls 2022-01-26 21:55:36 +00:00
0e438e0244 Implement clear 2022-01-26 21:38:41 +00:00
430c4d52da Formatting & remove redundant headline 2022-01-26 21:33:19 +00:00
d5a414266b Split build into dev and prod 2022-01-26 21:33:05 +00:00
47cbaf9660 Implement YNAB Import in Vue 2022-01-26 21:12:42 +00:00
7776ba90f0 Add interactive build to dev container 2022-01-26 20:47:08 +00:00
80c00171c8 Add dev docker-compose 2022-01-26 20:46:22 +00:00
9cc06bbe93 Add plain run command without docker and rename old run to rundocker 2022-01-26 20:45:46 +00:00
829881bd2a Fix missing exeExt in sources 2022-01-26 20:45:26 +00:00
0ad97ce4ff Remove unused HTML endpoints 2022-01-26 20:22:39 +00:00
1aa6d8d58f Add build script to dev container 2022-01-26 20:22:23 +00:00
db8553995c Add simple data stucture to README 2022-01-25 22:31:47 +00:00
de6054359a Remove dashboard call and fetch Budgets with login 2022-01-25 22:29:35 +00:00
9e3dde8076 Rename module 2022-01-25 20:58:32 +00:00
d6165c6ede Add Settings.vue 2022-01-25 20:56:57 +00:00
404bd6625f Add mutation-types.js 2022-01-25 20:56:43 +00:00
463a52fb19 Merge modules 2022-01-25 20:56:32 +00:00
ffed94f586 Implement dummy Budget-Settings and extract setTitle mutation 2022-01-25 20:40:00 +00:00
74c4c7cb02 Improve login/logout
Extract mutation types to mutation-types.js
2022-01-25 20:30:47 +00:00
6dcf7da861 Implement watch for accounts as well 2022-01-25 20:06:26 +00:00
4f2b3b7b22 Display selected budget and update sidebar on change 2022-01-25 19:25:11 +00:00
3b130b8621 Fix amount display and hide account 2022-01-25 19:20:54 +00:00
a4f94cd9d9 Remove old logging 2022-01-25 19:18:19 +00:00
c2bbaebfd2 Implement API endpoint for transactions 2022-01-25 19:18:08 +00:00
458f4f0e8f Implement account-view 2022-01-25 19:03:58 +00:00
e184ef933f Add container to wrap budget list 2022-01-25 12:54:33 +00:00
a2cc19d310 Use props for budgetid 2022-01-25 12:54:25 +00:00
626e0ada40 Add toggle menu button 2022-01-25 12:54:06 +00:00
339c230756 fetchBudget whenever current budget changes 2022-01-25 08:55:54 +00:00
59ae9f978a Rename Budget to BudgetSidebar 2022-01-25 08:55:39 +00:00
af3252277c Implement currentBudget and move infos to sidebar 2022-01-25 08:37:38 +00:00
33990bdccf Exclude node_modules and vendor in VSCode 2022-01-24 19:24:12 +00:00
5fb7542176 Add new budget dialog 2022-01-24 19:23:49 +00:00
c2bcd815d5 API returns Budget directly 2022-01-24 19:22:53 +00:00
be821bc90a Remove unneeded assignment 2022-01-24 19:22:38 +00:00
746680a4d7 Add empty styles file 2022-01-24 16:00:55 +00:00
fa4c9bcaba Add vuetify and webfontloader plugins 2022-01-24 16:00:39 +00:00
3de0447eba Implement register and login using vuetify 2022-01-24 16:00:01 +00:00
d59b9bdbec Add vuetify 2022-01-24 14:47:47 +00:00
93b1712fb5 Add Dockerfile for dev environment 2022-01-24 14:45:05 +00:00
b2b934cfb3 Add logout action 2022-01-23 22:38:38 +00:00
1d39fc7976 Remove old code for accounts 2022-01-23 22:38:25 +00:00
dd160cab17 Convert logout link to method 2022-01-23 22:38:12 +00:00
0e3ece9830 Add Getter for on/offbudget accounts and remove from backend 2022-01-23 22:33:36 +00:00
27298a9860 Add budget module to store 2022-01-23 22:24:40 +00:00
e26bb257cc Add dummy-logout link 2022-01-23 22:24:28 +00:00
0bd63636bb Implement Budget-List via Getter 2022-01-23 22:24:17 +00:00
6086447126 Implement budget display 2022-01-23 22:24:02 +00:00
aae8bbb44e Return JSON and move /budget/:budgetid to API endpoint 2022-01-23 22:23:30 +00:00
f33f0880c4 Move store to own folder 2022-01-23 21:54:46 +00:00
c069a33890 Remove logging after api request 2022-01-23 21:37:26 +00:00
8b4ebadec2 Only save partial state 2022-01-23 21:36:46 +00:00
2b556222ed Fix display of menu-items by login state 2022-01-23 21:35:43 +00:00
3da2e0f2f8 Remove authentication Cookies from Backend 2022-01-23 21:35:23 +00:00
4f72751ed6 Extract variable 2022-01-23 21:15:33 +00:00
c2326060b3 Rename index.js to routes.js 2022-01-22 16:23:10 +00:00
dad3a149d8 Use router-link to budget page 2022-01-22 16:21:53 +00:00
1bdd66a45a Remove HelloWorld and logging, add Budget page 2022-01-22 16:21:53 +00:00
b0ba63118e Add login page 2022-01-22 16:11:42 +00:00
18d11358b2 Remove logo 2022-01-22 16:11:34 +00:00
4af82805ff Save store to localStorage 2022-01-22 16:10:46 +00:00
15ab8a3dac Use Header instead of Cookie 2022-01-21 15:07:15 +00:00
99cf27b649 Return forbidden status in API 2022-01-21 15:07:01 +00:00
bc78cab715 Add proxy to backend to vite config 2022-01-21 14:20:58 +00:00
ddab5998bc Move dashboard to api 2022-01-21 14:20:45 +00:00
929db00a47 Get Dashboards on mounted
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-19 21:32:02 +00:00
8a6322bc7e Fix binding href 2022-01-19 21:32:02 +00:00
5601c965dc Fix script exporting computed 2022-01-19 21:32:02 +00:00
2fba4381df Allow access from other hosts 2022-01-19 21:32:02 +00:00
bdf942a17b Import esm-bundler 2022-01-19 21:32:02 +00:00
d354a680f9 Set version correctly 2022-01-19 21:32:02 +00:00
6899e4c88e Try to add store 2022-01-19 20:53:19 +00:00
e0f822dbcc Add vuex 2022-01-18 13:30:47 +00:00
d0e52ddfa6 Implement basic routing 2022-01-18 13:29:51 +00:00
2112192670 Add frontend to build script 2022-01-18 13:29:24 +00:00
13063b47ba Merge pull request 'Add row to create new transaction' (#2) from new-transaction-row into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #2
2022-01-18 11:43:20 +01:00
3cda536854 Add vue-router 2022-01-17 22:17:29 +00:00
6d49a549a0 Remove templating completely 2022-01-17 22:17:23 +00:00
aa33c148cb Remove templates and add vue template 2022-01-17 22:05:43 +00:00
663f247080 Import and display Status
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-16 15:43:05 +00:00
e094c2a740 Merge branch 'master' into new-transaction-row
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-01-10 11:18:56 +01:00
7fba9e7e4c Fix .drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-10 10:18:42 +00:00
cc58c0d012 Add row to create new transaction 2022-01-10 10:15:55 +00:00
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
a8edeaafa1 Disable docker build for prs 2022-01-10 10:14:10 +00:00
6fe30231d8 Save all unmatched transfers as regular transactions
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2022-01-10 10:10:56 +00:00
49af9cd2ef Fix logging wrong objects 2022-01-10 10:10:56 +00:00
ac27dc783e Also fetch GroupID and highlight groups in transactions-view 2022-01-10 10:10:56 +00:00
3c17d674f9 Fix migration 2022-01-10 10:10:56 +00:00
92725c0f26 Reword clear actions description
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-10 10:10:47 +00:00
2ec9c923df Implement matching 2022-01-10 10:10:02 +00:00
beff7afcf7 Add group_id 2022-01-10 10:10:02 +00:00
337d588c3c Include all accounts
All checks were successful
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
951e827d20 Add transfer_id 2022-01-09 20:47:43 +00:00
2f3e4bc748 Add transfers to list and skip 2022-01-09 20:27:51 +00:00
d71eb17092 Remove new transaction from transaction edit dialog
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-28 20:41:18 +00:00
53dd31fa35 Extract util.go 2021-12-28 20:41:06 +00:00
1a4267186a Add ability to edit payees 2021-12-28 20:40:53 +00:00
5018e5b973 Give sensible name to caching method
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-28 20:16:44 +00:00
ed9e75d57a Enable drone CI
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-28 20:08:19 +00:00
ed361324dd Fix indentation 2021-12-28 16:14:48 +00:00
6bac09a38e Implement update and delete for transactions 2021-12-27 23:38:30 +00:00
ab43387f06 Use wrapper to prevent password change popups from screwing up the layout 2021-12-27 23:14:50 +00:00
c112d95a41 Add delete button 2021-12-27 23:07:16 +00:00
6fdc0e3b1d Try to fix indentation 2021-12-27 23:07:07 +00:00
f08784ffa7 Fix typo 2021-12-27 23:06:59 +00:00
8188184ac9 Add tag for self-hosted registry 2021-12-27 23:06:38 +00:00
81b3bf334a Add transaction detail view 2021-12-14 15:02:51 +00:00
d0ad0dcb3a Implement categories for new transactions 2021-12-14 14:41:10 +00:00
1ab1fa74e0 Improve new-transaction form by adding account_id and default date 2021-12-14 14:15:08 +00:00
33c54c9f4c Make import available via UI 2021-12-14 14:14:35 +00:00
1ed9344586 Add home button to sidebar 2021-12-14 14:14:12 +00:00
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
9e01be699a Fix modals not opening 2021-12-14 14:12:01 +00:00
84ddb36d62 Fix reset only undoing one version 2021-12-14 14:11:11 +00:00
8b6a8c3697 Add income_category_id only after categories table exists 2021-12-11 22:08:08 +00:00
208ffce968 Wrap errors 2021-12-11 22:07:53 +00:00
bfba5f4028 Also rebuild on schema change
Files in postgres/schema/ are embedded in an embed.FS
2021-12-11 22:07:34 +00:00
1f2d81f173 Add task build command 2021-12-11 22:07:09 +00:00
c3a93377d9 Fix schema 2021-12-11 21:55:33 +00:00
40a299141d Implement new budget with transaction to be able to satisfy not null columns 2021-12-11 20:19:52 +00:00
935499e3a8 Improve UI
Highlight future transactions
clarify settings are for budget
2021-12-11 20:19:28 +00:00
915964fa4e Add now to funcs available from templates 2021-12-11 20:18:27 +00:00
e9adc763b2 Remove Repository and use Database instead 2021-12-11 20:18:09 +00:00
d5ebf5a5cf Group hidden categories 2021-12-11 15:10:51 +00:00
466775817f Merge added and assigned into moneyUsed 2021-12-11 13:03:26 +00:00
e2413290b4 Extract another method 2021-12-11 13:01:35 +00:00
18cd29cca2 Exctract getDate 2021-12-11 12:52:52 +00:00
caf0126b86 Implement budgeting views by calculating most values locally 2021-12-11 12:47:41 +00:00
6da1b26a2f update go.mod 2021-12-11 12:47:07 +00:00
13993b6b5a Try to calculate balances locally 2021-12-10 18:56:56 +00:00
625e0635fd Fix indentation 2021-12-10 17:09:43 +00:00
1826274ccc Show budget name in sidebar 2021-12-10 16:40:02 +00:00
defbbd1884 Improve documentation for YNAB Import 2021-12-10 16:39:56 +00:00
8116238d48 Add settings page linking to clean and clear 2021-12-10 16:39:44 +00:00
e0eeaadc60 Use same template for account and all-accounts 2021-12-10 16:38:10 +00:00
4cd81592e4 Also watch for templates changes 2021-12-10 16:37:11 +00:00
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
3bec0857d5 Add Taskfile 2021-12-10 09:36:16 +00:00
5e18d51b5d Only show last month's overflow 2021-12-08 15:21:10 +00:00
11179a1593 Also load available last month 2021-12-08 15:15:49 +00:00
7cb7527704 Use date_trunc instead of splitting date into year and month 2021-12-08 15:15:35 +00:00
c3a022b595 Add views with results grouped by month 2021-12-08 14:40:11 +00:00
a0ebdd01aa Implement cleaning to set all historic negative balances to zero 2021-12-07 21:59:06 +00:00
edd1319222 Show available balance including activities of this month 2021-12-07 21:32:36 +00:00
a19d3d6932 Try to enable caching 2021-12-07 21:32:20 +00:00
f4ddf12214 Split displayed accounts by on- or off-budget 2021-12-07 21:20:35 +00:00
04fd687324 Handle null values in numeric 2021-12-07 21:20:35 +00:00
cbda69e827 Handle on_budget in available balance 2021-12-07 21:20:35 +00:00
e3f3dc6748 Add on_budget column to accounts 2021-12-07 21:20:35 +00:00
915379f5cb Make available balance date-dependent 2021-12-07 21:20:35 +00:00
284685fb52 Update Bootstrap 2021-12-07 21:20:35 +00:00
5f4c5d9d51 Display zero values in grey 2021-12-07 20:35:49 +00:00
8c9c78a789 Fix between call being inclusive 2021-12-07 20:28:48 +00:00
64822912d9 Add available balance 2021-12-07 20:22:40 +00:00
1d4bc158a8 Improve handling of context 2021-12-07 19:08:53 +00:00
fbd283cd1c Show date in without time 2021-12-07 15:42:39 +00:00
0ee3f269b5 Split routes into own files 2021-12-07 15:42:29 +00:00
148 changed files with 12306 additions and 13109 deletions

View File

@ -7,4 +7,5 @@ config.example.json
.gitignore .gitignore
.vscode/ .vscode/
budgeteer budgeteer
budgeteer.exe budgeteer.exe
**/node_modules/

33
.drone.yml Normal file
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
- 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

8
.gitignore vendored
View File

@ -4,6 +4,7 @@
# Unignore all with extensions # Unignore all with extensions
!*.* !*.*
!Dockerfile
# Unignore all dirs # Unignore all dirs
!*/ !*/
@ -85,4 +86,9 @@ GitHub.sublime-settings
# Support for Project snippet scope # Support for Project snippet scope
!.vscode/*.code-snippets !.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

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"files.exclude": {
"**/node_modules": true,
"**/vendor": true
}
}

11
.vscode/tasks.json vendored
View File

@ -4,14 +4,21 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "earthly +run", "label": "task watch +run",
"type": "shell", "type": "shell",
"command": "earthly +run", "command": "task -w run",
"problemMatcher": [], "problemMatcher": [],
"group": { "group": {
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
} }
},
{
"label": "earthly +run",
"type": "shell",
"command": "earthly +run",
"problemMatcher": [],
"group": "build"
} }
] ]
} }

23
.woodpecker.yml Normal file
View File

@ -0,0 +1,23 @@
pipeline:
build:
name: Taskfile.dev
image: hub.javil.eu/budgeteer:dev
pull: true
commands:
- task
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

View File

@ -12,7 +12,7 @@ docker:
WORKDIR /app WORKDIR /app
COPY +build/budgeteer . COPY +build/budgeteer .
ENTRYPOINT ["/app/budgeteer"] ENTRYPOINT ["/app/budgeteer"]
SAVE IMAGE budgeteer:latest SAVE IMAGE hub.javil.eu/budgeteer:latest
run: run:
LOCALLY LOCALLY

View File

@ -1,3 +1,20 @@
# Budgeteer # 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

102
Taskfile.yml Normal file
View File

@ -0,0 +1,102 @@
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
- ./cmd/budgeteer/*.go
- ./*.go
- ./config/*.go
- ./http/*.go
- ./jwt/*.go
- ./postgres/*.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:
- task: build
build-prod:
desc: Build budgeteer in prod mode
deps: [gomod, sqlc, frontend]
cmds:
- task: build
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
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

3
build/Dockerfile Normal file
View File

@ -0,0 +1,3 @@
FROM scratch
COPY ./budgeteer /app/budgeteer
ENTRYPOINT ["/app/budgeteer"]

View File

@ -13,27 +13,19 @@ import (
func main() { func main() {
cfg, err := config.LoadConfig() cfg, err := config.LoadConfig()
if err != nil { if err != nil {
log.Fatalf("Could not load Config: %v", err) log.Fatalf("Could not load config: %v", err)
} }
bv := &bcrypt.Verifier{} q, err := postgres.Connect("pgx", cfg.DatabaseConnection)
q, db, err := postgres.Connect(cfg.DatabaseHost, cfg.DatabaseUser, cfg.DatabasePassword, cfg.DatabaseName)
if err != nil { if err != nil {
log.Fatalf("Failed connecting to DB: %v", err) log.Fatalf("Failed connecting to DB: %v", err)
} }
us, err := postgres.NewRepository(q, db)
if err != nil {
log.Fatalf("Failed building Repository: %v", err)
}
tv := &jwt.TokenVerifier{}
h := &http.Handler{ h := &http.Handler{
Service: us, Service: q,
TokenVerifier: tv, TokenVerifier: &jwt.TokenVerifier{},
CredentialsVerifier: bv, CredentialsVerifier: &bcrypt.Verifier{},
} }
h.Serve() h.Serve()
} }

View File

@ -6,23 +6,13 @@ import (
// Config contains all needed configurations // Config contains all needed configurations
type Config struct { type Config struct {
DatabaseUser string DatabaseConnection string
DatabaseHost string
DatabasePassword string
DatabaseName string
} }
// LoadConfig from path // LoadConfig from path
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
configuration := Config{ configuration := Config{
DatabaseUser: os.Getenv("BUDGETEER_DB_USER"), DatabaseConnection: os.Getenv("BUDGETEER_DB"),
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 return &configuration, nil

42
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,42 @@
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
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:

View File

@ -2,7 +2,7 @@ version: '3.7'
services: services:
app: app:
image: budgeteer:latest image: hub.javil.eu/budgeteer:latest
container_name: budgeteer container_name: budgeteer
ports: ports:
- 1323:1323 - 1323:1323

16
docker/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
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
FROM alpine
RUN apk add go
RUN apk add nodejs yarn bash curl git git-perl tmux
ADD docker/build.sh /
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /usr/local/bin/
RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}"
WORKDIR /src
ADD web/package.json /src/web/
RUN yarn
CMD /build.sh

7
docker/build.sh Executable file
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;

5
go.mod
View File

@ -5,13 +5,14 @@ go 1.17
require ( require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/gin-gonic/gin v1.7.4 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/google/uuid v1.3.0
github.com/jackc/pgx/v4 v4.13.0 github.com/jackc/pgx/v4 v4.13.0
github.com/pressly/goose/v3 v3.3.1 github.com/pressly/goose/v3 v3.3.1
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa
) )
require github.com/DATA-DOG/go-txdb v0.1.5 // indirect
require ( require (
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.13.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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.1.1 // indirect github.com/jackc/pgproto3/v2 v2.1.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // 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/json-iterator/go v1.1.9 // indirect
github.com/leodido/go-urn v1.2.0 // indirect github.com/leodido/go-urn v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect github.com/mattn/go-isatty v0.0.12 // indirect

2
go.sum
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/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/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/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 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 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= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=

37
http/account.go Normal file
View File

@ -0,0 +1,37 @@
package http
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
}

79
http/account_test.go Normal file
View File

@ -0,0 +1,79 @@
package http
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"
"github.com/gin-gonic/gin"
txdb "github.com/DATA-DOG/go-txdb"
)
func init() {
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
}
func TestListTimezonesHandler(t *testing.T) {
db, err := postgres.Connect("pgtx", "example")
if err != nil {
t.Errorf("could not connect to db: %s", err)
return
}
h := Handler{
Service: db,
TokenVerifier: &jwt.TokenVerifier{},
CredentialsVerifier: &bcrypt.Verifier{},
}
rr := httptest.NewRecorder()
c, engine := gin.CreateTestContext(rr)
h.LoadRoutes(engine)
t.Run("RegisterUser", func(t *testing.T) {
c.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(c)
if rr.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
}
var response LoginResponse
err = json.NewDecoder(rr.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) {
c.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
if rr.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
}
var response TransactionsResponse
err = json.NewDecoder(rr.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.")
}
})
}

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)
}

View File

@ -1,31 +1,112 @@
package http package http
import ( import (
"fmt"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/pressly/goose/v3" "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) { func (h *Handler) clearDatabase(c *gin.Context) {
d := AdminData{} if err := goose.Reset(h.Service.DB, "schema"); err != nil {
if err := goose.Down(h.Service.LegacyDB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
} }
if err := goose.Up(h.Service.LegacyDB, "schema"); err != nil { if err := goose.Up(h.Service.DB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
} }
}
c.HTML(http.StatusOK, "admin.html", d)
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
}
}*/
} }

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()
}

54
http/autocomplete.go Normal file
View File

@ -0,0 +1,54 @@
package http
import (
"fmt"
"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.AbortWithError(http.StatusBadRequest, fmt.Errorf("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.AbortWithError(http.StatusBadRequest, fmt.Errorf("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)
}

View File

@ -1,37 +1,36 @@
package http package http
import ( import (
"context" "fmt"
"net/http" "net/http"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
type BudgetData struct { type newBudgetInformation struct {
AlwaysNeededData Name string `json:"name"`
Transactions []postgres.GetTransactionsForBudgetRow
} }
func (h *Handler) budget(c *gin.Context) { func (h *Handler) newBudget(c *gin.Context) {
budgetID := c.Param("budgetid") var newBudget newBudgetInformation
budgetUUID, err := uuid.Parse(budgetID) err := c.BindJSON(&newBudget)
if err != nil { if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login") c.AbortWithError(http.StatusNotAcceptable, err)
return return
} }
transactions, err := h.Service.DB.GetTransactionsForBudget(context.Background(), budgetUUID) if newBudget.Name == "" {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("Budget name is needed"))
return
}
userID := c.MustGet("token").(budgeteer.Token).GetID()
budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
d := BudgetData{ c.JSON(http.StatusOK, budget)
c.MustGet("data").(AlwaysNeededData),
transactions,
}
c.HTML(http.StatusOK, "budget.html", d)
} }

View File

@ -1,7 +1,6 @@
package http package http
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -12,92 +11,206 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type BudgetingData struct { func getFirstOfMonth(year, month int, location *time.Location) time.Time {
AlwaysNeededData return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
Categories []postgres.GetCategoriesWithBalanceRow }
Date time.Time
Next time.Time func getFirstOfMonthTime(date time.Time) time.Time {
Previous 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.AbortWithError(http.StatusBadRequest, fmt.Errorf("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.AbortWithError(http.StatusInternalServerError, fmt.Errorf("load balances: %w", err))
return
}
// skip everything in the future
categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
if err != nil {
return
}
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)
}
}
data := struct {
Categories []CategoryWithBalance
AvailableBalance postgres.Numeric
}{categoriesWithBalance, availableBalance}
c.JSON(http.StatusOK, data)
} }
func (h *Handler) budgeting(c *gin.Context) { func (h *Handler) budgeting(c *gin.Context) {
budgetID := c.Param("budgetid") budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID) budgetUUID, err := uuid.Parse(budgetID)
if err != nil { if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login") c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
return return
} }
now := time.Now() budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
var year, month int if err != nil {
yearString := c.Param("year") c.AbortWithError(http.StatusNotFound, err)
monthString := c.Param("month") return
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()) accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
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 { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
d := BudgetingData{ data := struct {
c.MustGet("data").(AlwaysNeededData), Accounts []postgres.GetAccountsWithBalanceRow
categories, Budget postgres.Budget
firstOfMonth, }{accounts, budget}
firstOfNextMonth,
firstOfPreviousMonth,
}
c.HTML(http.StatusOK, "budgeting.html", d) c.JSON(http.StatusOK, data)
} }
func (h *Handler) clearBudget(c *gin.Context) { func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, postgres.Numeric, error) {
budgetID := c.Param("budgetid") categoriesWithBalance := []CategoryWithBalance{}
budgetUUID, err := uuid.Parse(budgetID) hiddenCategory := CategoryWithBalance{
if err != nil { GetCategoriesRow: &postgres.GetCategoriesRow{
c.Redirect(http.StatusTemporaryRedirect, "/login") Name: "",
return Group: "Hidden Categories",
},
Available: postgres.NewZeroNumeric(),
AvailableLastMonth: postgres.NewZeroNumeric(),
Activity: postgres.NewZeroNumeric(),
Assigned: postgres.NewZeroNumeric(),
} }
rows, err := h.Service.DB.DeleteAllAssignments(c.Request.Context(), budgetUUID) moneyUsed := postgres.NewZeroNumeric()
if err != nil { for i := range categories {
c.AbortWithError(http.StatusInternalServerError, err) cat := &categories[i]
return 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
}
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
}
}
// do not show hidden categories
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)
} }
fmt.Printf("Deleted %d assignments\n", rows) categoriesWithBalance = append(categoriesWithBalance, hiddenCategory)
rows, err = h.Service.DB.DeleteAllTransactions(c.Request.Context(), budgetUUID) return categoriesWithBalance, moneyUsed, nil
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
fmt.Printf("Deleted %d transactions\n", rows)
} }

View File

@ -10,7 +10,7 @@ import (
func (h *Handler) dashboard(c *gin.Context) { func (h *Handler) dashboard(c *gin.Context) {
userID := c.MustGet("token").(budgeteer.Token).GetID() userID := c.MustGet("token").(budgeteer.Token).GetID()
budgets, err := h.Service.BudgetsForUser(userID) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
if err != nil { if err != nil {
return return
} }
@ -18,7 +18,7 @@ func (h *Handler) dashboard(c *gin.Context) {
d := DashboardData{ d := DashboardData{
Budgets: budgets, Budgets: budgets,
} }
c.HTML(http.StatusOK, "dashboard.html", d) c.JSON(http.StatusOK, d)
} }
type DashboardData struct { type DashboardData struct {

View File

@ -1,10 +1,12 @@
package http package http
import ( import (
"fmt" "errors"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
"time" "path"
"strings"
"git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/bcrypt"
@ -12,57 +14,45 @@ import (
"git.javil.eu/jacob1123/budgeteer/web" "git.javil.eu/jacob1123/budgeteer/web"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
// Handler handles incoming requests // Handler handles incoming requests
type Handler struct { type Handler struct {
Service *postgres.Repository Service *postgres.Database
TokenVerifier budgeteer.TokenVerifier TokenVerifier budgeteer.TokenVerifier
CredentialsVerifier *bcrypt.Verifier CredentialsVerifier *bcrypt.Verifier
StaticFS http.FileSystem
} }
const ( const (
expiration = 72 expiration = 72
authCookie = "authentication"
) )
// Serve starts the HTTP Server // Serve starts the http server
func (h *Handler) Serve() { func (h *Handler) Serve() {
router := gin.Default() router := gin.Default()
h.LoadRoutes(router)
router.Run(":1323")
}
templates, err := NewTemplates(router.FuncMap) // LoadRoutes initializes all the routes
if err != nil { func (h *Handler) LoadRoutes(router *gin.Engine) {
panic(err) static, err := fs.Sub(web.Static, "dist")
}
router.HTMLRender = templates
static, err := fs.Sub(web.Static, "static")
if err != nil { if err != nil {
panic("couldn't open static files") panic("couldn't open static files")
} }
router.StaticFS("/static", http.FS(static)) h.StaticFS = http.FS(static)
router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) }) router.Use(enableCachingForStaticFiles())
router.GET("/login", h.login) router.NoRoute(h.ServeStatic)
router.GET("/register", h.register)
withLogin := router.Group("") withLogin := router.Group("")
withLogin.Use(h.verifyLoginWithRedirect) 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 := router.Group("")
withBudget.Use(h.verifyLoginWithRedirect) withBudget.Use(h.verifyLoginWithForbidden)
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/:year/:month", h.budgeting)
withBudget.GET("/budget/:budgetid/all-accounts", h.budget) withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
withBudget.GET("/budget/:budgetid/accounts", h.accounts)
withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
api := router.Group("/api/v1") api := router.Group("/api/v1")
@ -72,132 +62,59 @@ func (h *Handler) Serve() {
unauthenticated.POST("/register", h.registerPost) unauthenticated.POST("/register", h.registerPost)
authenticated := api.Group("") authenticated := api.Group("")
authenticated.Use(h.verifyLoginWithRedirect) authenticated.Use(h.verifyLoginWithForbidden)
authenticated.GET("/dashboard", h.dashboard)
user := authenticated.Group("/user") authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
user.GET("/logout", logout) 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 := authenticated.Group("/budget")
budget.POST("/new", h.newBudget) budget.POST("/new", h.newBudget)
transaction := authenticated.Group("/transaction") transaction := authenticated.Group("/transaction")
transaction.POST("/new", h.newTransaction) transaction.POST("/new", h.newTransaction)
transaction.POST("/import/ynab", h.importYNAB) transaction.POST("/:transactionid", h.newTransaction)
}
router.Run(":1323") func (h *Handler) ServeStatic(c *gin.Context) {
h.ServeStaticFile(c, c.Request.URL.Path)
} }
func (h *Handler) importYNAB(c *gin.Context) { func (h *Handler) ServeStaticFile(c *gin.Context, fullPath string) {
budgetID, succ := c.GetPostForm("budget_id") file, err := h.StaticFS.Open(fullPath)
if !succ { if errors.Is(err, fs.ErrNotExist) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified")) h.ServeStaticFile(c, path.Join("/", "/index.html"))
return return
} }
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := NewYNABImport(h.Service.DB, budgetUUID)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
transactionsFile, err := c.FormFile("transactions") stat, err := file.Stat()
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
transactions, err := transactionsFile.Open() if stat.IsDir() {
if err != nil { h.ServeStaticFile(c, path.Join(fullPath, "index.html"))
c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
err = ynab.ImportTransactions(transactions) http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file.(io.ReadSeeker))
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) { func enableCachingForStaticFiles() gin.HandlerFunc {
transactionMemo, succ := c.GetPostForm("memo") return func(c *gin.Context) {
if !succ { if strings.HasPrefix(c.Request.RequestURI, "/static/") {
c.AbortWithStatus(http.StatusNotAcceptable) c.Header("Cache-Control", "max-age=86400")
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
} }
} }

30
http/json-date.go Normal file
View File

@ -0,0 +1,30 @@
package http
import (
"encoding/json"
"strings"
"time"
)
type JSONDate time.Time
// Implement Marshaler and Unmarshaler interface
func (j *JSONDate) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"")
t, err := time.Parse("2006-01-02", s)
if err != nil {
return err
}
*j = JSONDate(t)
return nil
}
func (j JSONDate) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Time(j))
}
// Maybe a Format function for printing your date
func (j JSONDate) Format(s string) string {
t := time.Time(j)
return t.Format(s)
}

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"time"
"git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
@ -12,20 +11,32 @@ import (
) )
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) { func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) {
tokenString, err := c.Cookie(authCookie) tokenString := c.GetHeader("Authorization")
if err != nil { if len(tokenString) < 8 {
return nil, fmt.Errorf("get cookie: %w", err) return nil, fmt.Errorf("no authorization header supplied")
} }
tokenString = tokenString[7:]
token, err := h.TokenVerifier.VerifyToken(tokenString) token, err := h.TokenVerifier.VerifyToken(tokenString)
if err != nil { if err != nil {
c.SetCookie(authCookie, "", -1, "", "", false, false)
return nil, fmt.Errorf("verify token '%s': %w", tokenString, err) return nil, fmt.Errorf("verify token '%s': %w", tokenString, err)
} }
return token, nil return token, nil
} }
func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
//c.Header("WWW-Authenticate", "Bearer")
c.AbortWithError(http.StatusForbidden, err)
return
}
c.Set("token", token)
c.Next()
}
func (h *Handler) verifyLoginWithRedirect(c *gin.Context) { func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
token, err := h.verifyLogin(c) token, err := h.verifyLogin(c)
if err != nil { if err != nil {
@ -38,43 +49,25 @@ func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
c.Next() c.Next()
} }
func (h *Handler) login(c *gin.Context) { type loginInformation struct {
if _, err := h.verifyLogin(c); err == nil { Password string `json:"password"`
c.Redirect(http.StatusTemporaryRedirect, "/dashboard") User string `json:"user"`
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) { func (h *Handler) loginPost(c *gin.Context) {
username, _ := c.GetPostForm("username") var login loginInformation
password, _ := c.GetPostForm("password") err := c.BindJSON(&login)
if err != nil {
return
}
user, err := h.Service.DB.GetUserByUsername(context.Background(), username) user, err := h.Service.GetUserByUsername(c.Request.Context(), login.User)
if err != nil { if err != nil {
c.AbortWithError(http.StatusUnauthorized, err) c.AbortWithError(http.StatusUnauthorized, err)
return return
} }
if err = h.CredentialsVerifier.Verify(password, user.Password); err != nil { if err = h.CredentialsVerifier.Verify(login.Password, user.Password); err != nil {
c.AbortWithError(http.StatusUnauthorized, err) c.AbortWithError(http.StatusUnauthorized, err)
return return
} }
@ -84,38 +77,70 @@ func (h *Handler) loginPost(c *gin.Context) {
c.AbortWithError(http.StatusUnauthorized, err) c.AbortWithError(http.StatusUnauthorized, err)
} }
_, _ = h.Service.DB.UpdateLastLogin(context.Background(), user.ID) go h.Service.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) { budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
email, _ := c.GetPostForm("email") if err != nil {
password, _ := c.GetPostForm("password")
name, _ := c.GetPostForm("name")
_, err := h.Service.DB.GetUserByUsername(context.Background(), email)
if err == nil {
c.AbortWithStatus(http.StatusUnauthorized)
return return
} }
hash, err := h.CredentialsVerifier.Hash(password) c.JSON(http.StatusOK, LoginResponse{t, 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
c.BindJSON(&register)
if register.Email == "" || register.Password == "" || register.Name == "" {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("e-mail, password and name are required"))
return
}
_, err := h.Service.GetUserByUsername(c.Request.Context(), register.Email)
if err == nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("email is already taken"))
return
}
hash, err := h.CredentialsVerifier.Hash(register.Password)
if err != nil { if err != nil {
c.AbortWithError(http.StatusUnauthorized, err) c.AbortWithError(http.StatusBadRequest, err)
return return
} }
createUser := postgres.CreateUserParams{ createUser := postgres.CreateUserParams{
Name: name, Name: register.Name,
Password: hash, Password: hash,
Email: email, Email: register.Email,
} }
_, err = h.Service.DB.CreateUser(context.Background(), createUser) user, err := h.Service.CreateUser(c.Request.Context(), createUser)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
} }
t, err := h.TokenVerifier.CreateToken(&user)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
}
go h.Service.UpdateLastLogin(context.Background(), user.ID)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil {
return
}
c.JSON(http.StatusOK, LoginResponse{t, user, budgets})
} }

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,
}
}

89
http/transaction.go Normal file
View File

@ -0,0 +1,89 @@
package http
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:"budget_id"`
AccountID uuid.UUID `json:"account_id"`
State string `json:"state"`
}
func (h *Handler) newTransaction(c *gin.Context) {
var payload NewTransactionPayload
err := c.BindJSON(&payload)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
fmt.Printf("%v\n", payload)
amount := postgres.Numeric{}
amount.Set(payload.Amount)
/*transactionUUID, err := getNullUUIDFromParam(c, "transactionid")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("parse transaction id: %w", err))
return
}*/
//if !transactionUUID.Valid {
new := postgres.CreateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
AccountID: payload.AccountID,
PayeeID: payload.Payee.ID, //TODO handle new payee
CategoryID: payload.Category.ID, //TODO handle new category
Status: postgres.TransactionStatus(payload.State),
}
_, err = h.Service.CreateTransaction(c.Request.Context(), new)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
}
return
// }
/*
_, delete := c.GetPostForm("delete")
if delete {
err = h.Service.DeleteTransaction(c.Request.Context(), transactionUUID.UUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err))
}
return
}
update := postgres.UpdateTransactionParams{
ID: transactionUUID.UUID,
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
AccountID: transactionAccountID,
PayeeID: payload.Payee.ID, //TODO handle new payee
CategoryID: payload.Category.ID, //TODO handle new category
}
err = h.Service.UpdateTransaction(c.Request.Context(), update)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err))
}*/
}

56
http/util.go Normal file
View File

@ -0,0 +1,56 @@
package http
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func getUUID(c *gin.Context, name string) (uuid.UUID, error) {
value, succ := c.GetPostForm(name)
if !succ {
return uuid.UUID{}, fmt.Errorf("not set")
}
id, err := uuid.Parse(value)
if err != nil {
return uuid.UUID{}, fmt.Errorf("not a valid uuid: %w", err)
}
return id, nil
}
func getNullUUIDFromParam(c *gin.Context, name string) (uuid.NullUUID, error) {
value := c.Param(name)
if value == "" {
return uuid.NullUUID{}, nil
}
id, err := uuid.Parse(value)
if err != nil {
return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err)
}
return uuid.NullUUID{
UUID: id,
Valid: true,
}, nil
}
func getNullUUIDFromForm(c *gin.Context, name string) (uuid.NullUUID, error) {
value, succ := c.GetPostForm(name)
if !succ || value == "" {
return uuid.NullUUID{}, nil
}
id, err := uuid.Parse(value)
if err != nil {
return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err)
}
return uuid.NullUUID{
UUID: id,
Valid: true,
}, nil
}

View File

@ -1,299 +1,66 @@
package http package http
import ( import (
"context"
"encoding/csv"
"fmt" "fmt"
"io" "net/http"
"strings"
"time"
"unicode/utf8"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
type YNABImport struct { func (h *Handler) importYNAB(c *gin.Context) {
Context context.Context budgetID, succ := c.Params.Get("budgetid")
accounts []postgres.Account if !succ {
payees []postgres.Payee c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
categories []postgres.GetCategoriesRow return
categoryGroups []postgres.CategoryGroup }
queries *postgres.Queries
budgetID uuid.UUID budgetUUID, err := uuid.Parse(budgetID)
} if !succ {
c.AbortWithError(http.StatusBadRequest, err)
func NewYNABImport(q *postgres.Queries, budgetID uuid.UUID) (*YNABImport, error) { return
accounts, err := q.GetAccounts(context.Background(), budgetID) }
if err != nil {
return nil, err ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, budgetUUID)
} if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
payees, err := q.GetPayees(context.Background(), budgetID) return
if err != nil { }
return nil, err
} transactionsFile, err := c.FormFile("transactions")
if err != nil {
categories, err := q.GetCategories(context.Background(), budgetID) c.AbortWithError(http.StatusInternalServerError, err)
if err != nil { return
return nil, err }
}
transactions, err := transactionsFile.Open()
categoryGroups, err := q.GetCategoryGroups(context.Background(), budgetID) if err != nil {
if err != nil { c.AbortWithError(http.StatusInternalServerError, err)
return nil, err return
} }
return &YNABImport{ err = ynab.ImportTransactions(transactions)
Context: context.Background(), if err != nil {
accounts: accounts, c.AbortWithError(http.StatusInternalServerError, err)
payees: payees, return
categories: categories, }
categoryGroups: categoryGroups,
queries: q, assignmentsFile, err := c.FormFile("assignments")
budgetID: budgetID, if err != nil {
}, nil c.AbortWithError(http.StatusInternalServerError, err)
return
} }
func (ynab *YNABImport) ImportAssignments(r io.Reader) error { assignments, err := assignmentsFile.Open()
csv := csv.NewReader(r) if err != nil {
csv.Comma = '\t' c.AbortWithError(http.StatusInternalServerError, err)
csv.LazyQuotes = true return
}
csvData, err := csv.ReadAll()
if err != nil { err = ynab.ImportAssignments(assignments)
return fmt.Errorf("could not read from tsv: %w", err) if err != nil {
} c.AbortWithError(http.StatusInternalServerError, err)
return
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
} }

View File

@ -13,7 +13,7 @@ const createAccount = `-- name: CreateAccount :one
INSERT INTO accounts INSERT INTO accounts
(name, budget_id) (name, budget_id)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING id, budget_id, name RETURNING id, budget_id, name, on_budget
` `
type CreateAccountParams struct { type CreateAccountParams struct {
@ -24,24 +24,34 @@ type CreateAccountParams struct {
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) { func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, createAccount, arg.Name, arg.BudgetID) row := q.db.QueryRowContext(ctx, createAccount, arg.Name, arg.BudgetID)
var i Account 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 return i, err
} }
const getAccount = `-- name: GetAccount :one 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 WHERE accounts.id = $1
` `
func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error) { func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error) {
row := q.db.QueryRowContext(ctx, getAccount, id) row := q.db.QueryRowContext(ctx, getAccount, id)
var i Account 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 return i, err
} }
const getAccounts = `-- name: GetAccounts :many 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 WHERE accounts.budget_id = $1
ORDER BY accounts.name ORDER BY accounts.name
` `
@ -55,7 +65,12 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
var items []Account var items []Account
for rows.Next() { for rows.Next() {
var i Account 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 return nil, err
} }
items = append(items, i) items = append(items, i)
@ -70,19 +85,19 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
} }
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many 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 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 WHERE accounts.budget_id = $1
AND transactions.date < NOW()
GROUP BY accounts.id, accounts.name GROUP BY accounts.id, accounts.name
ORDER BY accounts.name ORDER BY accounts.name
` `
type GetAccountsWithBalanceRow struct { type GetAccountsWithBalanceRow struct {
ID uuid.UUID ID uuid.UUID
Name string Name string
Balance Numeric OnBudget bool
Balance Numeric
} }
func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID) ([]GetAccountsWithBalanceRow, error) { 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 var items []GetAccountsWithBalanceRow
for rows.Next() { for rows.Next() {
var i GetAccountsWithBalanceRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)

View File

@ -52,3 +52,37 @@ func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID)
} }
return result.RowsAffected() 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
}

View File

@ -5,38 +5,63 @@ package postgres
import ( import (
"context" "context"
"time"
"github.com/google/uuid" "github.com/google/uuid"
) )
const createBudget = `-- name: CreateBudget :one const createBudget = `-- name: CreateBudget :one
INSERT INTO budgets INSERT INTO budgets
(name, last_modification) (name, income_category_id, last_modification)
VALUES ($1, NOW()) VALUES ($1, $2, NOW())
RETURNING id, name, last_modification RETURNING id, name, last_modification, income_category_id
` `
func (q *Queries) CreateBudget(ctx context.Context, name string) (Budget, error) { type CreateBudgetParams struct {
row := q.db.QueryRowContext(ctx, createBudget, name) 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 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 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 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 WHERE id = $1
` `
func (q *Queries) GetBudget(ctx context.Context, id uuid.UUID) (Budget, error) { func (q *Queries) GetBudget(ctx context.Context, id uuid.UUID) (Budget, error) {
row := q.db.QueryRowContext(ctx, getBudget, id) row := q.db.QueryRowContext(ctx, getBudget, id)
var i Budget 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 return i, err
} }
const getBudgetsForUser = `-- name: GetBudgetsForUser :many 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 LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
WHERE user_budgets.user_id = $1 WHERE user_budgets.user_id = $1
` `
@ -50,7 +75,12 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
var items []Budget var items []Budget
for rows.Next() { for rows.Next() {
var i Budget 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 return nil, err
} }
items = append(items, i) items = append(items, i)
@ -63,3 +93,42 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
} }
return items, nil 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
}

View File

@ -2,39 +2,55 @@ package postgres
import ( import (
"context" "context"
"database/sql"
"fmt"
"github.com/google/uuid" "github.com/google/uuid"
) )
// Budget returns a budget for a given id. // NewBudget creates a budget and adds it to the current user
func (s *Repository) Budget(id uuid.UUID) (*Budget, error) { func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
budget, err := s.DB.GetBudget(context.Background(), id) tx, err := s.BeginTx(context, &sql.TxOptions{})
q := s.WithTx(tx)
budget, err := q.CreateBudget(context, CreateBudgetParams{
Name: name,
IncomeCategoryID: uuid.New(),
})
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("create budget: %w", err)
}
return &budget, nil
}
func (s *Repository) BudgetsForUser(id uuid.UUID) ([]Budget, error) {
budgets, err := s.DB.GetBudgetsForUser(context.Background(), id)
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
} }
ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID} ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
_, err = s.DB.LinkBudgetToUser(context.Background(), ub) _, err = q.LinkBudgetToUser(context, ub)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("link budget to user: %w", err)
} }
group, err := q.CreateCategoryGroup(context, CreateCategoryGroupParams{
Name: "Inflow",
BudgetID: budget.ID,
})
if err != nil {
return nil, fmt.Errorf("create inflow category_group: %w", err)
}
cat, err := q.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 = q.SetInflowCategory(context, SetInflowCategoryParams{
IncomeCategoryID: cat.ID,
ID: budget.ID,
})
if err != nil {
return nil, fmt.Errorf("set inflow category: %w", err)
}
tx.Commit()
return &budget, nil return &budget, nil
} }

View File

@ -5,7 +5,6 @@ package postgres
import ( import (
"context" "context"
"time"
"github.com/google/uuid" "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 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 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
` `
type GetCategoriesRow struct { type GetCategoriesRow struct {
@ -89,81 +89,6 @@ func (q *Queries) GetCategories(ctx context.Context, budgetID uuid.UUID) ([]GetC
return items, nil 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 const getCategoryGroups = `-- name: GetCategoryGroups :many
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
WHERE category_groups.budget_id = $1 WHERE category_groups.budget_id = $1
@ -191,3 +116,44 @@ func (q *Queries) GetCategoryGroups(ctx context.Context, budgetID uuid.UUID) ([]
} }
return items, nil 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
}

View File

@ -12,18 +12,25 @@ import (
//go:embed schema/*.sql //go:embed schema/*.sql
var migrations embed.FS var migrations embed.FS
type Database struct {
*Queries
*sql.DB
}
// Connect to a database // Connect to a database
func Connect(server string, user string, password string, database string) (*Queries, *sql.DB, error) { func Connect(typ string, connString string) (*Database, error) {
connString := fmt.Sprintf("postgres://%s:%s@%s/%s", user, password, server, database) conn, err := sql.Open(typ, connString)
conn, err := sql.Open("pgx", connString)
if err != nil { if err != nil {
return nil, nil, err return nil, fmt.Errorf("open connection: %w", err)
} }
goose.SetBaseFS(migrations) goose.SetBaseFS(migrations)
if err = goose.Up(conn, "schema"); err != nil { 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
} }

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
}

View File

@ -4,15 +4,37 @@ package postgres
import ( import (
"database/sql" "database/sql"
"fmt"
"time" "time"
"github.com/google/uuid" "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 { type Account struct {
ID uuid.UUID ID uuid.UUID
BudgetID uuid.UUID BudgetID uuid.UUID
Name string Name string
OnBudget bool
} }
type Assignment struct { type Assignment struct {
@ -23,10 +45,18 @@ type Assignment struct {
Amount Numeric Amount Numeric
} }
type AssignmentsByMonth struct {
Date time.Time
CategoryID uuid.UUID
BudgetID uuid.UUID
Amount int64
}
type Budget struct { type Budget struct {
ID uuid.UUID ID uuid.UUID
Name string Name string
LastModification sql.NullTime LastModification sql.NullTime
IncomeCategoryID uuid.UUID
} }
type Category struct { type Category struct {
@ -55,6 +85,15 @@ type Transaction struct {
AccountID uuid.UUID AccountID uuid.UUID
CategoryID uuid.NullUUID CategoryID uuid.NullUUID
PayeeID 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 { type User struct {

View File

@ -1,12 +1,24 @@
package postgres package postgres
import "github.com/jackc/pgtype" import (
"fmt"
"math/big"
"github.com/jackc/pgtype"
)
type Numeric struct { type Numeric struct {
pgtype.Numeric 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 { func (n Numeric) GetFloat64() float64 {
if n.Status != pgtype.Present {
return 0
}
var balance float64 var balance float64
err := n.AssignTo(&balance) err := n.AssignTo(&balance)
if err != nil { if err != nil {
@ -15,7 +27,103 @@ func (n Numeric) GetFloat64() float64 {
return balance return balance
} }
func (n Numeric) GetPositive() bool { func (n Numeric) IsPositive() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64() float := n.GetFloat64()
return float >= 0 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)
return Numeric{pgtype.Numeric{
Exp: exp,
Int: big.NewInt(0).Mul(n.Int, factor),
Status: n.Status,
NaN: n.NaN,
}}
}
func (n Numeric) Sub(o Numeric) Numeric {
left := n
right := o
if n.Exp < o.Exp {
right = o.MatchExp(n.Exp)
} else if n.Exp > o.Exp {
left = n.MatchExp(o.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(o Numeric) Numeric {
left := n
right := o
if n.Exp < o.Exp {
right = o.MatchExp(n.Exp)
} else if n.Exp > o.Exp {
left = n.MatchExp(o.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
}

View File

@ -31,6 +31,7 @@ func (q *Queries) CreatePayee(ctx context.Context, arg CreatePayeeParams) (Payee
const getPayees = `-- name: GetPayees :many const getPayees = `-- name: GetPayees :many
SELECT payees.id, payees.budget_id, payees.name FROM payees SELECT payees.id, payees.budget_id, payees.name FROM payees
WHERE payees.budget_id = $1 WHERE payees.budget_id = $1
ORDER BY name
` `
func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, error) { 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 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
}

View File

@ -14,10 +14,9 @@ WHERE accounts.budget_id = $1
ORDER BY accounts.name; ORDER BY accounts.name;
-- name: GetAccountsWithBalance :many -- 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 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 WHERE accounts.budget_id = $1
AND transactions.date < NOW()
GROUP BY accounts.id, accounts.name GROUP BY accounts.id, accounts.name
ORDER BY accounts.name; ORDER BY accounts.name;

View File

@ -11,3 +11,8 @@ DELETE FROM assignments
USING categories USING categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id 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; 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;

View File

@ -1,9 +1,14 @@
-- name: CreateBudget :one -- name: CreateBudget :one
INSERT INTO budgets INSERT INTO budgets
(name, last_modification) (name, income_category_id, last_modification)
VALUES ($1, NOW()) VALUES ($1, $2, NOW())
RETURNING *; RETURNING *;
-- name: SetInflowCategory :exec
UPDATE budgets
SET income_category_id = $1
WHERE budgets.id = $2;
-- name: GetBudgetsForUser :many -- name: GetBudgetsForUser :many
SELECT budgets.* FROM budgets SELECT budgets.* FROM budgets
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
@ -11,4 +16,22 @@ WHERE user_budgets.user_id = $1;
-- name: GetBudget :one -- name: GetBudget :one
SELECT * FROM budgets 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;

View File

@ -17,35 +17,13 @@ RETURNING *;
-- name: GetCategories :many -- name: GetCategories :many
SELECT categories.*, category_groups.name as group FROM categories SELECT categories.*, category_groups.name as group FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id 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 -- name: SearchCategories :many
SELECT categories.id, categories.name, category_groups.name as group, SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
(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
INNER JOIN category_groups ON categories.category_group_id = category_groups.id INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = @budget_id WHERE category_groups.budget_id = @budget_id
GROUP BY categories.id, categories.name, category_groups.name AND categories.name LIKE @search
ORDER BY category_groups.name, categories.name; ORDER BY category_groups.name, categories.name;
--ORDER BY levenshtein(payees.name, $2);

View File

@ -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);

View File

@ -6,4 +6,12 @@ RETURNING *;
-- name: GetPayees :many -- name: GetPayees :many
SELECT payees.* FROM payees 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);

View File

@ -1,11 +1,29 @@
-- name: GetTransaction :one
SELECT * FROM transactions
WHERE id = $1;
-- name: CreateTransaction :one -- name: CreateTransaction :one
INSERT INTO transactions INSERT INTO transactions
(date, memo, amount, account_id, payee_id, category_id) (date, memo, amount, account_id, payee_id, category_id, group_id, status)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *; 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 -- 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 accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
FROM transactions FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id INNER JOIN accounts ON accounts.id = transactions.account_id
@ -17,8 +35,19 @@ ORDER BY transactions.date DESC
LIMIT 200; LIMIT 200;
-- name: GetTransactionsForAccount :many -- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, SELECT transactions.id, transactions.date, transactions.memo,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category 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 FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id LEFT JOIN payees ON payees.id = transactions.payee_id
@ -32,4 +61,9 @@ LIMIT 200;
DELETE FROM transactions DELETE FROM transactions
USING accounts USING accounts
WHERE accounts.budget_id = @budget_id 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;

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
}

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;

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;

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;

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;

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;

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;

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;

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;

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;

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;

View File

@ -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;

View File

@ -0,0 +1,5 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "fuzzystrmatch";
-- +goose Down
DROP EXTENSION "fuzzystrmatch";

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;

View File

@ -12,9 +12,9 @@ import (
const createTransaction = `-- name: CreateTransaction :one const createTransaction = `-- name: CreateTransaction :one
INSERT INTO transactions INSERT INTO transactions
(date, memo, amount, account_id, payee_id, category_id) (date, memo, amount, account_id, payee_id, category_id, group_id, status)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, date, memo, amount, account_id, category_id, payee_id RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id, status
` `
type CreateTransactionParams struct { type CreateTransactionParams struct {
@ -24,6 +24,8 @@ type CreateTransactionParams struct {
AccountID uuid.UUID AccountID uuid.UUID
PayeeID uuid.NullUUID PayeeID uuid.NullUUID
CategoryID uuid.NullUUID CategoryID uuid.NullUUID
GroupID uuid.NullUUID
Status TransactionStatus
} }
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) { 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.AccountID,
arg.PayeeID, arg.PayeeID,
arg.CategoryID, arg.CategoryID,
arg.GroupID,
arg.Status,
) )
var i Transaction var i Transaction
err := row.Scan( err := row.Scan(
@ -44,6 +48,8 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
&i.AccountID, &i.AccountID,
&i.CategoryID, &i.CategoryID,
&i.PayeeID, &i.PayeeID,
&i.GroupID,
&i.Status,
) )
return i, err return i, err
} }
@ -63,9 +69,86 @@ func (q *Queries) DeleteAllTransactions(ctx context.Context, budgetID uuid.UUID)
return result.RowsAffected() 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 const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, SELECT transactions.id, transactions.date, transactions.memo,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category 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 FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id LEFT JOIN payees ON payees.id = transactions.payee_id
@ -77,14 +160,17 @@ LIMIT 200
` `
type GetTransactionsForAccountRow struct { type GetTransactionsForAccountRow struct {
ID uuid.UUID ID uuid.UUID
Date time.Time Date time.Time
Memo string Memo string
Amount Numeric Amount Numeric
Account string GroupID uuid.NullUUID
Payee string Status TransactionStatus
CategoryGroup string Account string
Category string Payee string
CategoryGroup string
Category string
TransferAccount interface{}
} }
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) { 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.Date,
&i.Memo, &i.Memo,
&i.Amount, &i.Amount,
&i.GroupID,
&i.Status,
&i.Account, &i.Account,
&i.Payee, &i.Payee,
&i.CategoryGroup, &i.CategoryGroup,
&i.Category, &i.Category,
&i.TransferAccount,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -120,7 +209,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
} }
const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many 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 accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
FROM transactions FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id INNER JOIN accounts ON accounts.id = transactions.account_id
@ -137,6 +226,8 @@ type GetTransactionsForBudgetRow struct {
Date time.Time Date time.Time
Memo string Memo string
Amount Numeric Amount Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string Account string
Payee string Payee string
CategoryGroup string CategoryGroup string
@ -157,6 +248,8 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
&i.Date, &i.Date,
&i.Memo, &i.Memo,
&i.Amount, &i.Amount,
&i.GroupID,
&i.Status,
&i.Account, &i.Account,
&i.Payee, &i.Payee,
&i.CategoryGroup, &i.CategoryGroup,
@ -174,3 +267,37 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
} }
return items, nil 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
}

383
postgres/ynab-import.go Normal file
View File

@ -0,0 +1,383 @@
package postgres
import (
"context"
"encoding/csv"
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
)
type YNABImport struct {
Context context.Context
accounts []Account
payees []Payee
categories []GetCategoriesRow
categoryGroups []CategoryGroup
queries *Queries
budgetID uuid.UUID
}
func NewYNABImport(context context.Context, q *Queries, budgetID uuid.UUID) (*YNABImport, error) {
accounts, err := q.GetAccounts(context, budgetID)
if err != nil {
return nil, err
}
payees, err := q.GetPayees(context, budgetID)
if err != nil {
return nil, err
}
categories, err := q.GetCategories(context, budgetID)
if err != nil {
return nil, err
}
categoryGroups, err := q.GetCategoryGroups(context, budgetID)
if err != nil {
return nil, err
}
return &YNABImport{
Context: context,
accounts: accounts,
payees: payees,
categories: categories,
categoryGroups: categoryGroups,
queries: q,
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(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:] {
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 := 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
}
type Transfer struct {
CreateTransactionParams
TransferToAccount *Account
FromAccount string
ToAccount string
}
// ImportTransactions expects a TSV-file as exported by YNAB in the following format:
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)
}
var openTransfers []Transfer
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)
}
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)
}
statusEnum := TransactionStatusUncleared
status := record[10]
switch status {
case "Cleared":
statusEnum = TransactionStatusCleared
case "Reconciled":
statusEnum = TransactionStatusReconciled
case "Uncleared":
}
transaction := CreateTransactionParams{
Date: date,
Memo: memo,
AccountID: account.ID,
CategoryID: category,
Amount: amount,
Status: statusEnum,
}
payeeName := record[3]
if strings.HasPrefix(payeeName, "Transfer : ") {
// Transaction is a transfer to
transferToAccountName := payeeName[11:]
transferToAccount, err := ynab.GetAccount(transferToAccountName)
if err != nil {
return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err)
}
transfer := Transfer{
transaction,
transferToAccount,
accountName,
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())
openTransfers[i] = openTransfers[len(openTransfers)-1]
openTransfers = openTransfers[:len(openTransfers)-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(ynab.Context, transfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", transfer.CreateTransactionParams, err)
}
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
}
break
}
if !found {
openTransfers = append(openTransfers, transfer)
}
} else {
payeeID, err := ynab.GetPayee(payeeName)
if err != nil {
return fmt.Errorf("could not get payee %s: %w", payeeName, err)
}
transaction.PayeeID = payeeID
_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", transaction, 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(ynab.Context, openTransfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
}
}
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) (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("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) (*Account, error) {
for _, acc := range ynab.accounts {
if acc.Name == name {
return &acc, nil
}
}
account, err := ynab.queries.CreateAccount(ynab.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(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, 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 := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
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
}
}
categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, 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, CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
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
}

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}}

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}}

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}}

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}}

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}}

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}}

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}}

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}}

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}}

View File

@ -1,17 +1,13 @@
{{define "title"}} <!DOCTYPE html>
Start <html lang="en">
{{end}} <head>
<meta charset="UTF-8" />
{{define "new"}} <link rel="icon" href="/favicon.ico" />
{{end}} <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
{{template "base" .}} </head>
<body>
{{define "main"}} <div id="app"></div>
<div class="container col-md-8 col-ld-8" id="content"> <script type="module" src="/src/main.ts"></script>
Willkommen bei Budgeteer, der neuen App für's Budget! </body>
</div> </html>
<div class="container col-md-4" id="login">
<a href="/login">Login</a> or <a href="/login">register</a>
</div>
{{end}}

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
web/package.json Normal file
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
web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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
web/src/@types/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module "*.vue" {
import { defineComponent } from "vue";
const component: ReturnType<typeof defineComponent>;
export default component;
}

65
web/src/App.vue Normal file
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
web/src/api.ts Normal file
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,
})
}

View File

@ -0,0 +1,101 @@
<script lang="ts">
import { defineComponent, PropType } 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[]
}
export default defineComponent({
data() {
return {
Selected: undefined,
SearchQuery: this.modelValue || "",
Suggestions: new Array<Suggestion>(),
} as Data
},
props: {
modelValue: Object as PropType<Suggestion>,
type: String
},
watch: {
SearchQuery() {
this.load(this.$data.SearchQuery);
}
},
methods: {
saveTransaction(e : MouseEvent) {
e.preventDefault();
},
load(text : String) {
this.$emit('update:modelValue', {ID: null, Name: text});
if (text == ""){
this.$data.Suggestions = [];
return;
}
const budgetStore = useBudgetsStore();
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + this.type + "?s=" + text)
.then(x=>x.json())
.then(x => {
let suggestions = x || [];
if(suggestions.length > 10){
suggestions = suggestions.slice(0, 10);
}
this.$data.Suggestions = suggestions;
});
},
keypress(e : KeyboardEvent) {
console.log(e.key);
if(e.key == "Enter") {
const selected = this.$data.Suggestions[0];
this.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();
}
},
selectElement(element : Suggestion) {
this.$data.Selected = element;
this.$data.Suggestions = [];
this.$emit('update:modelValue', element);
},
select(e : MouseEvent) {
const target = (<HTMLInputElement>e.target);
const valueAttribute = target.attributes.getNamedItem("value");
let selectedID = "";
if(valueAttribute != null)
selectedID = valueAttribute.value;
const selected = this.$data.Suggestions.filter(x => x.ID == selectedID)[0];
this.selectElement(selected);
},
clear() {
this.$data.Selected = undefined;
this.$emit('update:modelValue', {ID: null, Name: this.$data.SearchQuery});
}
}
})
</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>

View File

@ -0,0 +1,13 @@
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
})
</script>
<template>
<div class="flex flex-row items-center bg-gray-300 h-32 rounded-lg">
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
props: ["value"],
computed: {
formattedValue() {
return Number(this.value).toLocaleString(undefined, {
minimumFractionDigits: 2,
});
}
}
})
</script>
<template>
<span class="text-right" :class="value < 0 ? 'negative' : ''">{{formattedValue}} </span>
</template>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { mapState } from "pinia";
import { defineComponent } from "vue";
import { useBudgetsStore } from "../stores/budget";
import Currency from "./Currency.vue";
export default defineComponent({
props: [ "transaction", "index" ],
components: { Currency },
computed: {
...mapState(useBudgetsStore, ["CurrentBudgetID"])
}
})
</script>
<template>
<tr class="{{transaction.Date.After now ? 'future' : ''}}"
:class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']">
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
<td style="width: 90px;">{{ transaction.Date.substring(0, 10) }}</td>
<td style="max-width: 150px;">{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td>
<td style="max-width: 200px;">
{{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }}
</td>
<td>
<a :href="'/budget/' + CurrentBudgetID + '/transaction/' + transaction.ID">
{{ transaction.Memo }}
</a>
</td>
<td>
<Currency class="block" :value="transaction.Amount" />
</td>
<td style="width: 20px;">
{{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }}
</td>
<td style="width: 20px;">{{ transaction.GroupID ? "☀" : "" }}</td>
</tr>
</template>
<style>
td {
overflow: hidden;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import Card from '../components/Card.vue';
import { defineComponent } from "vue";
import { useBudgetsStore } from '../stores/budget';
export default defineComponent({
data() {
return {
dialog: false,
budgetName: ""
}
},
components: { Card },
methods: {
saveBudget() {
useBudgetsStore().NewBudget(this.$data.budgetName);
this.$data.dialog = false;
},
newBudget() {
this.$data.dialog = true;
}
}
})
</script>
<template>
<Card>
<p class="w-24 text-center text-6xl">+</p>
<button class="text-lg" dark @click="newBudget">New Budget</button>
</Card>
<div v-if="dialog" justify="center">
<div>
<div>
<span class="text-h5">New Budget</span>
</div>
<div>
<input type="text" v-model="budgetName" label="Budget name" required />
</div>
<div>
<button @click="dialog = false">Close</button>
<button @click="saveBudget">Save</button>
</div>
</div>
</div>
</template>

11
web/src/index.css Normal file
View File

@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
h1 {
font-size: 200%;
}
a {
text-decoration: underline;
}

25
web/src/main.ts Normal file
View File

@ -0,0 +1,25 @@
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
import router from './router'
import { createPinia } from 'pinia'
import { useBudgetsStore } from './stores/budget';
import { useAccountStore } from './stores/budget-account'
import PiniaLogger from './pinia-logger'
const app = createApp(App)
app.use(router)
const pinia = createPinia()
pinia.use(PiniaLogger())
app.use(pinia)
app.mount('#app')
router.beforeEach(async (to, from, next) => {
const budgetStore = useBudgetsStore();
await budgetStore.SetCurrentBudget((<string>to.params.budgetid));
const accountStore = useAccountStore();
await accountStore.SetCurrentAccount((<string>to.params.budgetid), (<string>to.params.accountid));
next();
})

97
web/src/pages/Account.vue Normal file
View File

@ -0,0 +1,97 @@
<script lang="ts" setup>
import { computed, ref } from "vue"
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
import Currency from "../components/Currency.vue";
import TransactionRow from "../components/TransactionRow.vue";
import { POST } from "../api";
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);
function saveTransaction(e: MouseEvent) {
e.preventDefault();
POST("/transaction/new", JSON.stringify({
budget_id: props.budgetid,
account_id: props.accountid,
date: TransactionDate.value,
payee: Payee.value,
category: Category.value,
memo: Memo.value,
amount: Amount,
state: "Uncleared"
}))
.then(x => x.json());
}
const accountStore = useAccountStore();
const CurrentAccount = accountStore.CurrentAccount;
const TransactionsList = accountStore.TransactionsList;
</script>
<template>
<h1>{{ CurrentAccount?.Name }}</h1>
<p>
Current Balance:
<Currency :value="CurrentAccount?.Balance" />
</p>
<table>
<tr class="font-bold">
<td style="width: 90px;">Date</td>
<td style="max-width: 150px;">Payee</td>
<td style="max-width: 200px;">Category</td>
<td>Memo</td>
<td class="text-right">Amount</td>
<td style="width: 20px;"></td>
<td style="width: 20px;"></td>
</tr>
<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>
<TransactionRow
v-for="(transaction, index) in TransactionsList"
:transaction="transaction"
:index="index"
/>
</table>
</template>
<style>
table {
width: 100%;
table-layout: fixed;
}
.negative {
color: red;
}
</style>

View File

@ -1,18 +1,15 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
{{define "title"}} onMounted(() => {
Admin document.title = "Budgeteer - Admin";
{{end}} })
</script>
{{define "sidebar"}} <template>
Settings for all Budgets
{{end}}
{{template "base" .}}
{{define "main"}}
<h1>Danger Zone</h1> <h1>Danger Zone</h1>
<div class="budget-item"> <div class="budget-item">
<button>Clear database</button> <button>Clear database</button>
<p>This removes all data and starts from scratch. Not undoable!</p> <p>This removes all data and starts from scratch. Not undoable!</p>
</div> </div>
{{end}} </template>

View File

@ -0,0 +1,71 @@
<script lang="ts" setup>
import { computed } from "vue";
import Currency from "../components/Currency.vue"
import { useBudgetsStore } from "../stores/budget"
import { useAccountStore } from "../stores/budget-account"
import { useSettingsStore } from "../stores/settings"
const props = defineProps<{
budgetid: string,
accountid: string,
}>();
const ExpandMenu = computed(() => useSettingsStore().Menu.Expand);
const budgetStore = useBudgetsStore();
const CurrentBudgetName = computed(() => budgetStore.CurrentBudgetName);
const CurrentBudgetID = computed(() => budgetStore.CurrentBudgetID);
const accountStore = useAccountStore();
const OnBudgetAccounts = computed(() => accountStore.OnBudgetAccounts);
const OffBudgetAccounts = computed(() => accountStore.OffBudgetAccounts);
const OnBudgetAccountsBalance = computed(() => accountStore.OnBudgetAccountsBalance);
const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBalance);
</script>
<template>
<div class="flex flex-col">
<span class="m-1 p-1 px-3 text-xl">
<router-link to="/dashboard"></router-link>
{{CurrentBudgetName}}
</span>
<span class="bg-orange-200 rounded-lg m-1 p-1 px-3 flex flex-col">
<router-link :to="'/budget/'+budgetid+'/budgeting'">Budget</router-link><br />
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
</span>
<li class="bg-orange-200 rounded-lg m-1 p-1 px-3">
<div class="flex flex-row justify-between font-bold">
<span>On-Budget Accounts</span>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" />
</div>
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
<router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
</div>
</li>
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
<div class="flex flex-row justify-between font-bold">
<span>Off-Budget Accounts</span>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" />
</div>
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between">
<router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
</div>
</li>
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
Closed Accounts
</li>
<!--<li>
<router-link :to="'/budget/'+CurrentBudgetID+'/accounts'">Edit accounts</router-link>
</li>-->
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
+ Add Account
</li>
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
<router-link :to="'/budget/'+CurrentBudgetID+'/settings'">Budget-Settings</router-link>
</li>
<!--<li><router-link to="/admin">Admin</router-link></li>-->
</div>
</template>

View File

@ -0,0 +1,95 @@
<script lang="ts" setup>
import { computed, defineProps, onMounted, PropType, watch, watchEffect } from "vue";
import Currency from "../components/Currency.vue";
import { useBudgetsStore } from "../stores/budget";
import { useAccountStore } from "../stores/budget-account";
interface Date {
Year: number,
Month: number,
}
const props = defineProps<{
budgetid: string,
year: string,
month: string,
}>()
const budgetsStore = useBudgetsStore();
const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
const categoriesForMonth = useAccountStore().CategoriesForMonth;
const Categories = computed(() => {
return [...categoriesForMonth(selected.value.Year, selected.value.Month)];
});
const previous = computed(() => ({
Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(),
Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(),
}));
const current = computed(() => ({
Year: new Date().getFullYear(),
Month: new Date().getMonth(),
}));
const selected = computed(() => ({
Year: Number(props.year) ?? current.value.Year,
Month: Number(props.month ?? current.value.Month)
}));
const next = computed(() => ({
Year: new Date(selected.value.Year, Number(props.month) + 1, 1).getFullYear(),
Month: new Date(selected.value.Year, Number(props.month) + 1, 1).getMonth(),
}));
watchEffect(() => {
if (props.year != undefined && props.month != undefined)
return useAccountStore().FetchMonthBudget(props.budgetid ?? "", Number(props.year), Number(props.month));
});
/*{{define "title"}}
{{printf "Budget for %s %d" .Date.Month .Date.Year}}
{{end}}*/
</script>
<template>
<h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1>
<div>
<router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
>Previous Month</router-link>-
<router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + current.Year + '/' + current.Month"
>Current Month</router-link>-
<router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + next.Year + '/' + next.Month"
>Next Month</router-link>
</div>
<table class="container col-lg-12" id="content">
<tr>
<th>Group</th>
<th>Category</th>
<th></th>
<th></th>
<th>Leftover</th>
<th>Assigned</th>
<th>Activity</th>
<th>Available</th>
</tr>
<tr v-for="category in Categories">
<td>{{ category.Group }}</td>
<td>{{ category.Name }}</td>
<td></td>
<td></td>
<td class="text-right">
<Currency :value="category.AvailableLastMonth" />
</td>
<td class="text-right">
<Currency :value="category.Assigned" />
</td>
<td class="text-right">
<Currency :value="category.Activity" />
</td>
<td class="text-right">
<Currency :value="category.Available" />
</td>
</tr>
</table>
</template>

View File

@ -0,0 +1,25 @@
<script lang="ts" setup>
import NewBudget from '../dialogs/NewBudget.vue';
import Card from '../components/Card.vue';
import { useSessionStore } from '../stores/session';
const props = defineProps<{
budgetid: string,
}>();
const BudgetsList = useSessionStore().BudgetsList;
</script>
<template>
<h1>Budgets</h1>
<div class="grid md:grid-cols-2 gap-6">
<Card v-for="budget in BudgetsList">
<router-link v-bind:to="'/budget/'+budget.ID+'/budgeting'" class="contents">
<!--<svg class="w-24"></svg>-->
<p class="w-24 text-center text-6xl"></p>
<span class="text-lg">{{budget.Name}}{{budget.ID == budgetid ? " *" : ""}}</span>
</router-link>
</Card>
<NewBudget />
</div>
</template>

13
web/src/pages/Index.vue Normal file
View File

@ -0,0 +1,13 @@
<script lang="ts" setup>
</script>
<template>
<div>
<div class="font-bold" id="content">
Willkommen bei Budgeteer, der neuen App für's Budget!
</div>
<div class="container col-md-4" id="login">
<router-link to="/login">Login</router-link> or <router-link to="/login">register</router-link>
</div>
</div>
</template>

48
web/src/pages/Login.vue Normal file
View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useSessionStore } from "../stores/session";
const error = ref("");
const login = ref({ user: "", password: "" });
onMounted(() => {
document.title = "Budgeteer - Login";
});
function formSubmit(e: MouseEvent) {
e.preventDefault();
useSessionStore().login(login)
.then(x => {
error.value = "";
useRouter().replace("/dashboard");
})
.catch(x => error.value = "The entered credentials are invalid!");
// TODO display invalidCredentials
// TODO redirect to dashboard on success
}
</script>
<template>
<div>
<input
type="text"
v-model="login.user"
placeholder="Username"
class="border-2 border-black rounded-lg block px-2 my-2 w-48"
/>
<input
type="password"
v-model="login.password"
placeholder="Password"
class="border-2 border-black rounded-lg block px-2 my-2 w-48"
/>
</div>
<div>{{ error }}</div>
<button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Login</button>
<p>
New user?
<router-link to="/register">Register</router-link>instead!
</p>
</template>

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