155 Commits
0.3.2 ... 0.4.2

Author SHA1 Message Date
98890f10eb Merge pull request 'Implement editing of transactions' (#23) from edit-transaction into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #23
2022-02-25 23:35:10 +01:00
5621d63436 Actually call backend for edit
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-25 22:34:09 +00:00
05099e469f Delete transaction when amount is zero 2022-02-25 22:33:58 +00:00
ae9e9d34c9 Use type instead of isAccount flag
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-25 22:28:22 +00:00
32439e3e87 rename type to model 2022-02-25 22:16:41 +00:00
4ed15b740b Remove unneeded imports and methods 2022-02-25 22:14:56 +00:00
5f161b2163 Rename Transaction to TX to match input row 2022-02-25 22:14:13 +00:00
10ea73663f Extract UpdateTransaction 2022-02-25 22:09:59 +00:00
07804e4241 Update existing transaction if transactionid was passed 2022-02-25 22:05:15 +00:00
2f4f8a7568 Extract method CreateTransferForOtherAccount 2022-02-25 22:02:57 +00:00
13d0194632 Pass date using ISO without time
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-25 21:52:56 +00:00
c864666eb6 Use local date format
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-25 21:50:15 +00:00
464931babe Pass amount as string 2022-02-25 21:46:20 +00:00
480a95e096 Implement custom date input 2022-02-25 21:45:58 +00:00
2d37ec147c Pass only category Id as categories are not to be created on the fly 2022-02-25 21:21:55 +00:00
75b48be20d Also pass Suggestion-Object from EditRow 2022-02-25 21:20:41 +00:00
306edbf817 Update TransactionInputRow to new models 2022-02-25 21:19:34 +00:00
be3829baf8 Replace modelValue by models for id and name 2022-02-25 21:10:21 +00:00
a452482381 Normalize transaction store 2022-02-25 20:47:58 +00:00
0f6990407d Remove widths from rows as header decides 2022-02-25 20:37:49 +00:00
97be5abc8c Add abilty to switch to edit mode 2022-02-25 20:37:31 +00:00
1e80ba6ca8 Also return PayeeID and CategoryID from backend 2022-02-25 20:36:24 +00:00
1331304639 Extract EditAccount Dialog 2022-02-25 20:17:54 +00:00
7435ac3667 Merge pull request 'Implement YNAB Export from UI' (#22) from export-from-ui into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #22
2022-02-25 21:07:40 +01:00
55dffbbe89 Implmeent expand/collapse of category-groups
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-25 20:06:26 +00:00
212c81ab81 Implement download from UI 2022-02-25 20:06:10 +00:00
5b5b8215c3 Merge pull request 'Make ynab export equivalent to original export' (#21) from ynab-export-fixes into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #21
2022-02-25 16:35:18 +01:00
79c0fceafe Fix formatting for negative numbers
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-25 15:32:39 +00:00
1ea3590fd6 Add tests with negative numbers 2022-02-25 15:32:32 +00:00
5bb2c9c8b8 Use empty string as fallback for TransferAccount
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-25 15:27:06 +00:00
f9e512c593 Fix floating points < 1 2022-02-25 15:26:51 +00:00
4f72351ee9 Add more unittests for numeric 2022-02-25 15:26:39 +00:00
9ed4df7053 Fix leading space before category separator
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-25 15:04:30 +00:00
78389e4beb Also export transfers 2022-02-25 15:04:09 +00:00
8ac3c22826 Merge pull request 'Implement editing of Accounts' (#20) from account-edit into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #20
2022-02-24 23:15:54 +01:00
ab07d3472d Handle EditAccount in Store
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-24 22:12:35 +00:00
466df523ab Implement EditAccount in Frontend 2022-02-24 22:12:26 +00:00
f51807e459 Implement EditAccount in Backend 2022-02-24 22:12:10 +00:00
03d1d1e520 Use grid instead of width for buttons 2022-02-24 21:38:57 +00:00
d09f5be69b Extract modal component 2022-02-24 21:37:26 +00:00
b5a03b40db Merge pull request 'Implement transfer creation' (#19) from enable-transfers into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #19
2022-02-24 00:13:20 +01:00
966c0ce0eb Redesign Budget Settings and introduce Button component
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-23 23:12:39 +00:00
635f4de402 Fix initialize of Budgets after login
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-23 23:03:31 +00:00
ddf51b5922 Use store instead of props in BudgetSidebar 2022-02-23 23:03:18 +00:00
bbbeff92e8 Reset amount to positive after saving transfer transaction
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-23 22:55:15 +00:00
5ccec61465 Implement transfer creation
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-23 22:53:04 +00:00
696fbee7cc Merge pull request 'Extract package for Numeric datatype and add unittests' (#18) from numeric-package into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #18
2022-02-23 23:13:18 +01:00
7c694fb32c Skip account_test when no db available
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-23 22:12:09 +00:00
5f746f5889 Also run tests in CI
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2022-02-23 22:10:16 +00:00
24e5b18ded Implement linter fixes
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-23 22:08:47 +00:00
253ecd1720 Add negative testcases
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2022-02-23 22:04:19 +00:00
f445f19233 Write all tests equally
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2022-02-23 22:02:29 +00:00
ea6d198bff Add some unit-tests for numeric
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2022-02-23 21:59:14 +00:00
28c20aacd3 Extract package 2022-02-23 21:59:14 +00:00
d89a8f4e2e Switch between payees and accounts depending on prefix 2022-02-23 21:59:14 +00:00
dae9abeeea Merge pull request 'Handle more of categories locally and update Modal' (#17) from categories-improvements into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #17
2022-02-23 22:58:33 +01:00
510c91205d Rewrite categories to be nested below groups
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-23 21:56:20 +00:00
f93888cbbc Move logic for hidden categories to client 2022-02-23 21:56:20 +00:00
674bef667b Hide checkmark icon 2022-02-23 21:56:20 +00:00
fffc91269e Extract NewCategoryWithBalance 2022-02-23 21:56:20 +00:00
c3175b9be6 Update NewBudget to use modal again 2022-02-23 21:56:20 +00:00
1d81aa2fb3 Do not try to set categories, if none are available 2022-02-23 21:56:20 +00:00
e5cf439231 Update BudgetSettings to tailwindcss 2022-02-23 21:56:20 +00:00
cfd2388de0 Disable cache for apk calls and add yarn.lock 2022-02-23 21:56:20 +00:00
0dfa698ada Merge pull request 'Fix inverted condition for Authorization and update expected JSON body' (#16) from minor-fixes into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2022-02-23 22:56:04 +01:00
b4aec52d4f Fix changed json contract
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-23 21:55:29 +00:00
b5e3e7bea0 Fix inverted condition 2022-02-23 21:55:21 +00:00
e98e0d4779 Merge pull request 'Implement Export in YNAB-Format' (#15) from ynab-export into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2022-02-23 22:18:01 +01:00
6686904539 Use zero Numeric for export instead of hardcoding 0,00
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-23 21:17:43 +00:00
0478d82c1f Fix order of fields
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-23 21:09:33 +00:00
34b6e450de Handle nil in Numeric 2022-02-23 21:09:25 +00:00
bc65249c03 Split transactions and assignments export into two endpoints
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-23 20:52:29 +00:00
e0dc7800af Remove limit for GetAllTransactionsForBudget 2022-02-23 20:52:29 +00:00
a7cd3512bb Handle errors of Write and add dots to comments 2022-02-23 20:52:29 +00:00
ece610419f Disable linter lll because no exceptions in comments are possible 2022-02-23 20:52:29 +00:00
27188e2e27 Implement ynab-export 2022-02-23 20:52:29 +00:00
4c7c61e820 Add string method to numeric 2022-02-23 20:52:29 +00:00
2adb70fa01 Fix comment and add csv example 2022-02-23 20:52:29 +00:00
eeb2d425e5 Merge pull request 'Fix registration not displaying' (#14) from registration into master
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #14
2022-02-22 14:44:29 +01:00
484b1062e1 Fix returning number as string for numbers
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-22 13:42:44 +00:00
a4ca21bb37 Add missing spaces
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-21 21:42:16 +00:00
ffabf1bca9 Also fix in register
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-21 21:41:24 +00:00
e9d4ed1b3e Fix router initialization in eventhandler
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
useRouter has to be called in setup or returns undefined otherwise.
See https://github.com/vuejs/vue-router/issues/3379
2022-02-21 21:40:38 +00:00
4085868cd7 Update register to tailwindcss
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-21 21:26:12 +00:00
4019656e4d Merge pull request 'Improve handling of transactions' (#12) from transaction-handling into master
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #12
2022-02-21 00:03:55 +01:00
16b7049438 Add Transaction to list after saving
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-20 23:01:25 +00:00
73e3b49b40 Extract component for new Transaction 2022-02-20 23:00:09 +00:00
953d348bed Handle new Payees 2022-02-20 23:00:07 +00:00
a6eb2a2253 Merge pull request 'Add code linting to build' (#13) from linting into master
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #13
2022-02-20 23:53:47 +01:00
578e7d071c Get session secret from env instead of hardcoding
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-20 22:51:54 +00:00
4688d2d94d Remove commented code and TODOs 2022-02-20 22:46:51 +00:00
cfda327a5d Enable linting nfor CI
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-20 22:41:56 +00:00
1bd38bb367 Move comment
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-20 22:40:58 +00:00
19d2ddbd65 Minor improvements 2022-02-20 22:37:34 +00:00
3cb39d978a Fix multiple linter errors 2022-02-20 22:33:29 +00:00
e08a21b750 Disable linter varnamelen 2022-02-20 22:33:15 +00:00
6929c940c4 Improve ynab import by extracting methods
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-20 21:51:07 +00:00
2423bdd3ee Improvements
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-20 21:33:18 +00:00
22ec0433bf Disable gci 2022-02-20 21:33:05 +00:00
c03d16878a Minimal linting improvements 2022-02-20 21:19:28 +00:00
545f223a97 Comments 2022-02-20 21:06:17 +00:00
260ac2d4ad Run gci 2022-02-20 21:03:55 +00:00
d815e8c3cd Ignore forcetypeassert for token map 2022-02-20 21:00:30 +00:00
91b8cc06b2 imports, comments and formatting 2022-02-20 20:58:48 +00:00
75a6ce1577 Use all gofiles as sources for go build 2022-02-20 20:53:18 +00:00
a0d89ee93a Revert "UNSURE explicitly init NullUUIDs"
This reverts commit 62085cb694.
2022-02-20 20:52:15 +00:00
e39d1dc6e3 Disable exhaustivstruct 2022-02-20 20:52:01 +00:00
1dcd0d2f6d Reformat config.go 2022-02-20 20:51:10 +00:00
62085cb694 UNSURE explicitly init NullUUIDs 2022-02-20 20:46:20 +00:00
ca51ac5e27 Use AbortWithStatusJSON instead of AbortWithError
All checks were successful
continuous-integration/drone/pr Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
continuous-integration/drone/push Build is passing
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-20 20:42:57 +00:00
36b2f12183 Renames 2022-02-20 20:42:27 +00:00
96b514ccf8 Fix capitalization of errors 2022-02-20 20:42:16 +00:00
b52ed21d1d Remove cost and just use defaultCost 2022-02-20 20:42:07 +00:00
787165b7f1 Disable ifshort 2022-02-20 20:41:58 +00:00
8035403416 Use camelCase for json identifiers 2022-02-20 16:51:48 +00:00
1a1971246d Use short syntax for errcheck where possible 2022-02-20 16:51:28 +00:00
9b92e2b551 Run tests in parallel 2022-02-20 16:51:17 +00:00
c5be03ab6b Extract error consts 2022-02-20 16:51:01 +00:00
77afe700ae Fix missing value usage
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-19 22:13:55 +00:00
f0961ccc3c Disable linting for prod build while open errors exist but enable for dev instead
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-19 22:06:11 +00:00
1d2ae0e394 Renames and dots in comments
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-19 22:04:51 +00:00
b0175542f1 Return own error 2022-02-19 22:04:35 +00:00
558fddc139 Disable linter testpackage 2022-02-19 22:04:27 +00:00
02ba80a555 Some linting fixes 2022-02-19 21:53:30 +00:00
1a19d3a197 Enable formatting via gofumpt 2022-02-19 21:53:13 +00:00
052a2628ab Disable forbidigo and nlreturn linters 2022-02-19 21:42:19 +00:00
46b9b82f30 Small refactorings 2022-02-19 21:38:57 +00:00
c5a0f49719 Disable wsl linter
Seems to be excessive
2022-02-19 21:38:38 +00:00
649f937254 Remove context from YNABImport struct 2022-02-19 21:28:28 +00:00
0f2501dcbd Rename parameter to other 2022-02-19 21:28:18 +00:00
daadfd45bc Rename http package to server 2022-02-19 21:28:04 +00:00
72b5bdde4f Minor fixes
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-15 12:59:53 +00:00
737d5fb101 Fix wrong executable name 2022-02-15 12:59:41 +00:00
bb4548c50d Improve error messages
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-15 12:41:12 +00:00
7b20bc9822 Disable deprecated linters 2022-02-15 12:38:30 +00:00
2f45c415e0 Move init of StaticFS and rename some vars 2022-02-15 12:37:23 +00:00
74a53954de Wrap more errors 2022-02-15 12:37:04 +00:00
7a0c4a17a2 Ignore gin.Context for varnamelen 2022-02-15 12:36:33 +00:00
71c54c9373 Enable all linters 2022-02-15 12:36:19 +00:00
584e7ef393 Add explicit CI task to Taskfile 2022-02-15 12:27:15 +00:00
aaf16dbe92 Add deps for dev-docker target 2022-02-15 12:26:55 +00:00
d8e0f5a160 Add explicit CI task to Taskfile 2022-02-15 12:26:48 +00:00
38dfa540b4 Fix issues from golangci 2022-02-15 11:52:06 +00:00
835a15ec08 Ad config for golangci
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-15 11:51:38 +00:00
4bbbc0be13 Add vet fmt and linters to build
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
ci/woodpecker/pr/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-15 09:33:17 +00:00
8f6974e151 Add golang-ci to dev image 2022-02-15 09:30:54 +00:00
368ac7f15d Merge pull request 'Use vue's Composition API in components' (#11) 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: #11
2022-02-15 10:13:47 +01:00
0d20d9bfb8 Use setTitle everywhere
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-15 08:35:29 +00:00
4276c51268 Remove unused interface
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-15 08:27:09 +00:00
57930d0e5d Rewrite addCategoriesForMonth with patch call
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-15 08:25:41 +00:00
fe018e1953 Reformat 2022-02-15 08:25:30 +00:00
e7a085273b Fix missing computed calls in Account 2022-02-15 08:25:12 +00:00
5bbd096fc8 Convert other components 2022-02-15 08:20:04 +00:00
452d63c329 Define Transaction interface and use number instead of Number 2022-02-15 08:04:42 +00:00
d28c894d21 Convert NewBudget and TransactionRow 2022-02-15 08:04:25 +00:00
73 changed files with 2554 additions and 1489 deletions

View File

@ -8,7 +8,7 @@ steps:
image: hub.javil.eu/budgeteer:dev image: hub.javil.eu/budgeteer:dev
pull: true pull: true
commands: commands:
- task - task ci
- name: docker - name: docker
image: plugins/docker image: plugins/docker

27
.golangci.yml Normal file
View File

@ -0,0 +1,27 @@
linters:
enable-all: true
disable:
- golint
- scopelint
- maligned
- interfacer
- wsl
- forbidigo
- nlreturn
- testpackage
- ifshort
- exhaustivestruct
- gci # not working, shows errors on freshly formatted file
- varnamelen
- lll
linters-settings:
errcheck:
exclude-functions:
- io/ioutil.ReadFile
- io.Copy(*bytes.Buffer)
- (*github.com/gin-gonic/gin.Context).AbortWithError
- (*github.com/gin-gonic/gin.Context).AbortWithError
- io.Copy(os.Stdout)
varnamelen:
ignore-decls:
- c *gin.Context

View File

@ -2,5 +2,8 @@
"files.exclude": { "files.exclude": {
"**/node_modules": true, "**/node_modules": true,
"**/vendor": true "**/vendor": true
},
"gopls": {
"formatting.gofumpt": true,
} }
} }

View File

@ -4,7 +4,7 @@ pipeline:
image: hub.javil.eu/budgeteer:dev image: hub.javil.eu/budgeteer:dev
pull: true pull: true
commands: commands:
- task - task ci
docker: docker:
image: plugins/docker image: plugins/docker

View File

@ -33,12 +33,7 @@ tasks:
sources: sources:
- ./go.mod - ./go.mod
- ./go.sum - ./go.sum
- ./cmd/budgeteer/*.go - ./**/*.go
- ./*.go
- ./config/*.go
- ./http/*.go
- ./jwt/*.go
- ./postgres/*.go
- ./web/dist/**/* - ./web/dist/**/*
- ./postgres/schema/* - ./postgres/schema/*
generates: generates:
@ -52,14 +47,26 @@ tasks:
desc: Build budgeteer in dev mode desc: Build budgeteer in dev mode
deps: [gomod, sqlc] deps: [gomod, sqlc]
cmds: cmds:
- go vet
- go fmt
- golangci-lint run
- task: build - task: build
build-prod: build-prod:
desc: Build budgeteer in prod mode desc: Build budgeteer in prod mode
deps: [gomod, sqlc, frontend] deps: [gomod, sqlc, frontend]
cmds: cmds:
- go vet
- go fmt
- golangci-lint run
- task: build - task: build
ci:
desc: Run CI build
cmds:
- task: build-prod
- go test ./...
frontend: frontend:
desc: Build vue frontend desc: Build vue frontend
dir: web dir: web
@ -85,6 +92,8 @@ tasks:
desc: Build budgeeter:dev desc: Build budgeeter:dev
sources: sources:
- ./docker/Dockerfile - ./docker/Dockerfile
- ./docker/build.sh
- ./web/package.json
cmds: cmds:
- docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile - docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile
- docker push {{.IMAGE_NAME}}:dev - docker push {{.IMAGE_NAME}}:dev

View File

@ -1,23 +1,30 @@
package bcrypt package bcrypt
import "golang.org/x/crypto/bcrypt" import (
"fmt"
// Verifier verifys passwords using Bcrypt "golang.org/x/crypto/bcrypt"
type Verifier struct { )
cost int
}
// Verify verifys a Password // Verifier verifys passwords using Bcrypt.
func (bv *Verifier) Verify(password string, hashOnDb string) error { type Verifier struct{}
return bcrypt.CompareHashAndPassword([]byte(hashOnDb), []byte(password))
}
// Hash calculates a hash to be stored on the database // Verify verifys a Password.
func (bv *Verifier) Hash(password string) (string, error) { func (bv *Verifier) Verify(password string, hashOnDB string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bv.cost) err := bcrypt.CompareHashAndPassword([]byte(hashOnDB), []byte(password))
if err != nil { if err != nil {
return "", err return fmt.Errorf("verify password: %w", err)
} }
return string(hash[:]), nil return nil
}
// Hash calculates a hash to be stored on the database.
func (bv *Verifier) Hash(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(hash), nil
} }

View File

@ -1,13 +1,16 @@
package main package main
import ( import (
"io/fs"
"log" "log"
"net/http"
"git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/config" "git.javil.eu/jacob1123/budgeteer/config"
"git.javil.eu/jacob1123/budgeteer/http"
"git.javil.eu/jacob1123/budgeteer/jwt" "git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/server"
"git.javil.eu/jacob1123/budgeteer/web"
) )
func main() { func main() {
@ -16,16 +19,24 @@ func main() {
log.Fatalf("Could not load config: %v", err) log.Fatalf("Could not load config: %v", err)
} }
q, err := postgres.Connect("pgx", cfg.DatabaseConnection) queries, err := postgres.Connect("pgx", cfg.DatabaseConnection)
if err != nil { if err != nil {
log.Fatalf("Failed connecting to DB: %v", err) log.Fatalf("Failed connecting to DB: %v", err)
} }
h := &http.Handler{ static, err := fs.Sub(web.Static, "dist")
Service: q, if err != nil {
TokenVerifier: &jwt.TokenVerifier{}, panic("couldn't open static files")
CredentialsVerifier: &bcrypt.Verifier{},
} }
h.Serve() handler := &server.Handler{
Service: queries,
TokenVerifier: &jwt.TokenVerifier{
Secret: cfg.SessionSecret,
},
CredentialsVerifier: &bcrypt.Verifier{},
StaticFS: http.FS(static),
}
handler.Serve()
} }

View File

@ -4,15 +4,17 @@ import (
"os" "os"
) )
// Config contains all needed configurations // Config contains all needed configurations.
type Config struct { type Config struct {
DatabaseConnection string DatabaseConnection string
SessionSecret string
} }
// LoadConfig from path // LoadConfig from path.
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
configuration := Config{ configuration := Config{
DatabaseConnection: os.Getenv("BUDGETEER_DB"), DatabaseConnection: os.Getenv("BUDGETEER_DB"),
SessionSecret: os.Getenv("BUDGETEER_SESSION_SECRET"),
} }
return &configuration, nil return &configuration, nil

View File

@ -17,6 +17,7 @@ services:
- ~/.cache:/.cache - ~/.cache:/.cache
environment: environment:
BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer
BUDGETEER_SESSION_SECRET: random string for JWT authorization
depends_on: depends_on:
- db - db

View File

@ -1,16 +1,16 @@
FROM alpine as godeps FROM alpine as godeps
RUN apk add go RUN apk --no-cache add go
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
RUN go install github.com/go-task/task/v3/cmd/task@latest RUN go install github.com/go-task/task/v3/cmd/task@latest
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
FROM alpine FROM alpine
RUN apk add go RUN apk --no-cache add go nodejs yarn bash curl git git-perl tmux
RUN apk add nodejs yarn bash curl git git-perl tmux
ADD docker/build.sh / ADD docker/build.sh /
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /usr/local/bin/
RUN yarn global add @vue/cli RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}" ENV PATH="/root/.yarn/bin/:${PATH}"
WORKDIR /src WORKDIR /src
ADD web/package.json /src/web/ ADD web/package.json web/yarn.lock /src/web/
RUN yarn RUN yarn
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
CMD /build.sh CMD /build.sh

View File

@ -1,54 +0,0 @@
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,216 +0,0 @@
package http
import (
"fmt"
"net/http"
"strconv"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
}
func getFirstOfMonthTime(date time.Time) time.Time {
var monthM time.Month
year, monthM, _ := date.Date()
month := int(monthM)
return getFirstOfMonth(year, month, date.Location())
}
type CategoryWithBalance struct {
*postgres.GetCategoriesRow
Available postgres.Numeric
AvailableLastMonth postgres.Numeric
Activity postgres.Numeric
Assigned postgres.Numeric
}
func getDate(c *gin.Context) (time.Time, error) {
var year, month int
yearString := c.Param("year")
monthString := c.Param("month")
if yearString == "" && monthString == "" {
return getFirstOfMonthTime(time.Now()), nil
}
year, err := strconv.Atoi(yearString)
if err != nil {
return time.Time{}, fmt.Errorf("parse year: %w", err)
}
month, err = strconv.Atoi(monthString)
if err != nil {
return time.Time{}, fmt.Errorf("parse month: %w", err)
}
return getFirstOfMonth(year, month, time.Now().Location()), nil
}
func (h *Handler) budgetingForMonth(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.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) {
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.StatusNotFound, err)
return
}
accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
data := struct {
Accounts []postgres.GetAccountsWithBalanceRow
Budget postgres.Budget
}{accounts, budget}
c.JSON(http.StatusOK, data)
}
func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, postgres.Numeric, error) {
categoriesWithBalance := []CategoryWithBalance{}
hiddenCategory := CategoryWithBalance{
GetCategoriesRow: &postgres.GetCategoriesRow{
Name: "",
Group: "Hidden Categories",
},
Available: postgres.NewZeroNumeric(),
AvailableLastMonth: postgres.NewZeroNumeric(),
Activity: postgres.NewZeroNumeric(),
Assigned: postgres.NewZeroNumeric(),
}
moneyUsed := postgres.NewZeroNumeric()
for i := range categories {
cat := &categories[i]
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)
}
categoriesWithBalance = append(categoriesWithBalance, hiddenCategory)
return categoriesWithBalance, moneyUsed, nil
}

View File

@ -1,89 +0,0 @@
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))
}*/
}

View File

@ -1,56 +0,0 @@
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,66 +0,0 @@
package http
import (
"fmt"
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) importYNAB(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactionsFile, err := c.FormFile("transactions")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactions, err := transactionsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportTransactions(transactions)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignmentsFile, err := c.FormFile("assignments")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignments, err := assignmentsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportAssignments(assignments)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}

View File

@ -10,11 +10,12 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// TokenVerifier verifies Tokens // TokenVerifier verifies Tokens.
type TokenVerifier struct { type TokenVerifier struct {
Secret string
} }
// Token contains everything to authenticate a user // Token contains everything to authenticate a user.
type Token struct { type Token struct {
username string username string
name string name string
@ -24,10 +25,9 @@ type Token struct {
const ( const (
expiration = 72 expiration = 72
secret = "uditapbzuditagscwxuqdflgzpbu´ßiaefnlmzeßtrubiadern"
) )
// CreateToken creates a new token from username and name // CreateToken creates a new token from username and name.
func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) { func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"usr": user.Email, "usr": user.Email,
@ -37,21 +37,27 @@ func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
}) })
// Generate encoded token and send it as response. // Generate encoded token and send it as response.
t, err := token.SignedString([]byte(secret)) t, err := token.SignedString([]byte(tv.Secret))
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("create token: %w", err)
} }
return t, nil return t, nil
} }
// VerifyToken verifys a given string-token var (
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { ErrUnexpectedSigningMethod = fmt.Errorf("unexpected signing method")
ErrInvalidToken = fmt.Errorf("token is invalid")
ErrTokenExpired = fmt.Errorf("token has expired")
)
// VerifyToken verifys a given string-token.
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) return nil, fmt.Errorf("method '%v': %w", token.Header["alg"], ErrUnexpectedSigningMethod)
} }
return []byte(secret), nil return []byte(tv.Secret), nil
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("parse jwt: %w", err) return nil, fmt.Errorf("parse jwt: %w", err)
@ -62,7 +68,7 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error
return nil, fmt.Errorf("verify jwt: %w", err) return nil, fmt.Errorf("verify jwt: %w", err)
} }
tkn := &Token{ tkn := &Token{ //nolint:forcetypeassert
username: claims["usr"].(string), username: claims["usr"].(string),
name: claims["name"].(string), name: claims["name"].(string),
expiry: claims["exp"].(float64), expiry: claims["exp"].(float64),
@ -73,16 +79,16 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error
func verifyToken(token *jwt.Token) (jwt.MapClaims, error) { func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
if !token.Valid { if !token.Valid {
return nil, fmt.Errorf("Token is not valid") return nil, ErrInvalidToken
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok { if !ok {
return nil, fmt.Errorf("Claims are not of Type MapClaims") return nil, ErrInvalidToken
} }
if !claims.VerifyExpiresAt(time.Now().Unix(), true) { if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return nil, fmt.Errorf("Claims have expired") return nil, ErrTokenExpired
} }
return claims, nil return claims, nil

View File

@ -6,6 +6,7 @@ package postgres
import ( import (
"context" "context"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -97,7 +98,7 @@ type GetAccountsWithBalanceRow struct {
ID uuid.UUID ID uuid.UUID
Name string Name string
OnBudget bool OnBudget bool
Balance Numeric Balance numeric.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) {
@ -127,3 +128,76 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
} }
return items, nil return items, nil
} }
const searchAccounts = `-- name: SearchAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
WHERE accounts.budget_id = $1
AND accounts.name LIKE $2
ORDER BY accounts.name
`
type SearchAccountsParams struct {
BudgetID uuid.UUID
Search string
}
type SearchAccountsRow struct {
ID uuid.UUID
BudgetID uuid.UUID
Name string
Type interface{}
}
func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams) ([]SearchAccountsRow, error) {
rows, err := q.db.QueryContext(ctx, searchAccounts, arg.BudgetID, arg.Search)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SearchAccountsRow
for rows.Next() {
var i SearchAccountsRow
if err := rows.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.Type,
); 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 updateAccount = `-- name: UpdateAccount :one
UPDATE accounts
SET name = $1,
on_budget = $2
WHERE accounts.id = $3
RETURNING id, budget_id, name, on_budget
`
type UpdateAccountParams struct {
Name string
OnBudget bool
ID uuid.UUID
}
func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, updateAccount, arg.Name, arg.OnBudget, arg.ID)
var i Account
err := row.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.OnBudget,
)
return i, err
}

View File

@ -7,6 +7,7 @@ import (
"context" "context"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -21,7 +22,7 @@ RETURNING id, category_id, date, memo, amount
type CreateAssignmentParams struct { type CreateAssignmentParams struct {
Date time.Time Date time.Time
Amount Numeric Amount numeric.Numeric
CategoryID uuid.UUID CategoryID uuid.UUID
} }
@ -53,6 +54,49 @@ func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID)
return result.RowsAffected() return result.RowsAffected()
} }
const getAllAssignments = `-- name: GetAllAssignments :many
SELECT assignments.date, categories.name as category, category_groups.name as group, assignments.amount
FROM assignments
INNER JOIN categories ON categories.id = assignments.category_id
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1
`
type GetAllAssignmentsRow struct {
Date time.Time
Category string
Group string
Amount numeric.Numeric
}
func (q *Queries) GetAllAssignments(ctx context.Context, budgetID uuid.UUID) ([]GetAllAssignmentsRow, error) {
rows, err := q.db.QueryContext(ctx, getAllAssignments, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllAssignmentsRow
for rows.Next() {
var i GetAllAssignmentsRow
if err := rows.Scan(
&i.Date,
&i.Category,
&i.Group,
&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 getAssignmentsByMonthAndCategory = `-- name: GetAssignmentsByMonthAndCategory :many const getAssignmentsByMonthAndCategory = `-- name: GetAssignmentsByMonthAndCategory :many
SELECT date, category_id, budget_id, amount SELECT date, category_id, budget_id, amount
FROM assignments_by_month FROM assignments_by_month

View File

@ -8,11 +8,15 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// NewBudget creates a budget and adds it to the current user // NewBudget creates a budget and adds it to the current user.
func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) { func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
tx, err := s.BeginTx(context, &sql.TxOptions{}) tx, err := s.BeginTx(context, &sql.TxOptions{})
q := s.WithTx(tx) if err != nil {
budget, err := q.CreateBudget(context, CreateBudgetParams{ return nil, fmt.Errorf("begin transaction: %w", err)
}
transaction := s.WithTx(tx)
budget, err := transaction.CreateBudget(context, CreateBudgetParams{
Name: name, Name: name,
IncomeCategoryID: uuid.New(), IncomeCategoryID: uuid.New(),
}) })
@ -21,12 +25,12 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
} }
ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID} ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
_, err = q.LinkBudgetToUser(context, ub) _, err = transaction.LinkBudgetToUser(context, ub)
if err != nil { if err != nil {
return nil, fmt.Errorf("link budget to user: %w", err) return nil, fmt.Errorf("link budget to user: %w", err)
} }
group, err := q.CreateCategoryGroup(context, CreateCategoryGroupParams{ group, err := transaction.CreateCategoryGroup(context, CreateCategoryGroupParams{
Name: "Inflow", Name: "Inflow",
BudgetID: budget.ID, BudgetID: budget.ID,
}) })
@ -34,7 +38,7 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
return nil, fmt.Errorf("create inflow category_group: %w", err) return nil, fmt.Errorf("create inflow category_group: %w", err)
} }
cat, err := q.CreateCategory(context, CreateCategoryParams{ cat, err := transaction.CreateCategory(context, CreateCategoryParams{
Name: "Ready to Assign", Name: "Ready to Assign",
CategoryGroupID: group.ID, CategoryGroupID: group.ID,
}) })
@ -42,7 +46,7 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
return nil, fmt.Errorf("create ready to assign category: %w", err) return nil, fmt.Errorf("create ready to assign category: %w", err)
} }
err = q.SetInflowCategory(context, SetInflowCategoryParams{ err = transaction.SetInflowCategory(context, SetInflowCategoryParams{
IncomeCategoryID: cat.ID, IncomeCategoryID: cat.ID,
ID: budget.ID, ID: budget.ID,
}) })
@ -50,7 +54,10 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
return nil, fmt.Errorf("set inflow category: %w", err) return nil, fmt.Errorf("set inflow category: %w", err)
} }
tx.Commit() err = tx.Commit()
if err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &budget, nil return &budget, nil
} }

View File

@ -118,7 +118,8 @@ func (q *Queries) GetCategoryGroups(ctx context.Context, budgetID uuid.UUID) ([]
} }
const searchCategories = `-- name: SearchCategories :many const searchCategories = `-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type
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
AND categories.name LIKE $2 AND categories.name LIKE $2
@ -133,6 +134,7 @@ type SearchCategoriesParams struct {
type SearchCategoriesRow struct { type SearchCategoriesRow struct {
Name interface{} Name interface{}
ID uuid.UUID ID uuid.UUID
Type interface{}
} }
func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) { func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) {
@ -144,7 +146,7 @@ func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesPara
var items []SearchCategoriesRow var items []SearchCategoriesRow
for rows.Next() { for rows.Next() {
var i SearchCategoriesRow var i SearchCategoriesRow
if err := rows.Scan(&i.Name, &i.ID); err != nil { if err := rows.Scan(&i.Name, &i.ID, &i.Type); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)

View File

@ -5,7 +5,7 @@ import (
"embed" "embed"
"fmt" "fmt"
_ "github.com/jackc/pgx/v4/stdlib" _ "github.com/jackc/pgx/v4/stdlib" // needed for pg connection
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
) )
@ -17,7 +17,7 @@ type Database struct {
*sql.DB *sql.DB
} }
// Connect to a database // Connect connects to a database.
func Connect(typ string, connString string) (*Database, error) { func Connect(typ string, connString string) (*Database, error) {
conn, err := sql.Open(typ, connString) conn, err := sql.Open(typ, connString)
if err != nil { if err != nil {

View File

@ -7,6 +7,7 @@ import (
"context" "context"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -23,10 +24,10 @@ ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id
type GetCumultativeBalancesRow struct { type GetCumultativeBalancesRow struct {
Date time.Time Date time.Time
CategoryID uuid.UUID CategoryID uuid.UUID
Assignments Numeric Assignments numeric.Numeric
AssignmentsCum Numeric AssignmentsCum numeric.Numeric
Transactions Numeric Transactions numeric.Numeric
TransactionsCum Numeric TransactionsCum numeric.Numeric
} }
func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) { func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) {

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -42,7 +43,7 @@ type Assignment struct {
CategoryID uuid.UUID CategoryID uuid.UUID
Date time.Time Date time.Time
Memo sql.NullString Memo sql.NullString
Amount Numeric Amount numeric.Numeric
} }
type AssignmentsByMonth struct { type AssignmentsByMonth struct {
@ -81,7 +82,7 @@ type Transaction struct {
ID uuid.UUID ID uuid.UUID
Date time.Time Date time.Time
Memo string Memo string
Amount Numeric Amount numeric.Numeric
AccountID uuid.UUID AccountID uuid.UUID
CategoryID uuid.NullUUID CategoryID uuid.NullUUID
PayeeID uuid.NullUUID PayeeID uuid.NullUUID

View File

@ -1,129 +0,0 @@
package postgres
import (
"fmt"
"math/big"
"github.com/jackc/pgtype"
)
type Numeric struct {
pgtype.Numeric
}
func NewZeroNumeric() Numeric {
return Numeric{pgtype.Numeric{Exp: 0, Int: big.NewInt(0), Status: pgtype.Present, NaN: false}}
}
func (n Numeric) GetFloat64() float64 {
if n.Status != pgtype.Present {
return 0
}
var balance float64
err := n.AssignTo(&balance)
if err != nil {
panic(err)
}
return balance
}
func (n Numeric) IsPositive() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float >= 0
}
func (n Numeric) IsZero() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float == 0
}
func (n Numeric) MatchExp(exp int32) Numeric {
diffExp := n.Exp - exp
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil)
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
}

226
postgres/numeric/numeric.go Normal file
View File

@ -0,0 +1,226 @@
package numeric
import (
"fmt"
"math/big"
"strings"
"unicode/utf8"
"github.com/jackc/pgtype"
)
type Numeric struct {
pgtype.Numeric
}
func Zero() Numeric {
return Numeric{pgtype.Numeric{Exp: 0, Int: big.NewInt(0), Status: pgtype.Present, NaN: false}}
}
func FromInt64(value int64) Numeric {
return Numeric{Numeric: pgtype.Numeric{Int: big.NewInt(value), Status: pgtype.Present}}
}
func FromInt64WithExp(value int64, exp int32) Numeric {
return Numeric{Numeric: pgtype.Numeric{Int: big.NewInt(value), Exp: exp, Status: pgtype.Present}}
}
func (n Numeric) GetFloat64() float64 {
if n.Status != pgtype.Present {
return 0
}
var balance float64
err := n.AssignTo(&balance)
if err != nil {
panic(err)
}
return balance
}
func (n Numeric) IsPositive() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float >= 0
}
func (n Numeric) IsZero() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float == 0
}
func (n Numeric) MatchExp(exp int32) Numeric {
diffExp := n.Exp - exp
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd
return Numeric{pgtype.Numeric{
Exp: exp,
Int: big.NewInt(0).Mul(n.Int, factor),
Status: n.Status,
NaN: n.NaN,
}}
}
func (n Numeric) Sub(other Numeric) Numeric {
left := n
right := other
if n.Exp < other.Exp {
right = other.MatchExp(n.Exp)
} else if n.Exp > other.Exp {
left = n.MatchExp(other.Exp)
}
if left.Exp == right.Exp {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Sub(left.Int, right.Int),
}}
}
panic("Cannot subtract with different exponents")
}
func (n Numeric) Neg() Numeric {
return Numeric{pgtype.Numeric{Exp: n.Exp, Int: big.NewInt(-1 * n.Int.Int64()), Status: n.Status}}
}
func (n Numeric) Add(other Numeric) Numeric {
left := n
right := other
if n.Exp < other.Exp {
right = other.MatchExp(n.Exp)
} else if n.Exp > other.Exp {
left = n.MatchExp(other.Exp)
}
if left.Exp == right.Exp {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Add(left.Int, right.Int),
}}
}
panic("Cannot add with different exponents")
}
func (n Numeric) String() string {
if n.Int == nil || n.Int.Int64() == 0 {
return "0"
}
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 string(bytes)
}
length := int32(len(bytes))
var bytesWithSeparator []byte
exp = -exp
for length <= exp {
if n.Int.Int64() < 0 {
bytes = append([]byte{bytes[0], byte('0')}, bytes[1:]...)
} else {
bytes = append([]byte{byte('0')}, bytes...)
}
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 string(bytesWithSeparator)
}
func (n Numeric) MarshalJSON() ([]byte, error) {
if n.Int == nil || 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 {
if n.Int.Int64() < 0 {
bytes = append([]byte{bytes[0], byte('0')}, bytes[1:]...)
} else {
bytes = append([]byte{byte('0')}, bytes...)
}
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
}
func MustParse(text string) Numeric {
num, err := Parse(text)
if err != nil {
panic(err)
}
return num
}
func Parse(text string) (Numeric, error) {
// Unify decimal separator
text = strings.Replace(text, ",", ".", 1)
num := Numeric{}
err := num.Set(text)
if err != nil {
return num, fmt.Errorf("parse numeric %s: %w", text, err)
}
return num, nil
}
func ParseCurrency(text string) (Numeric, error) {
// Remove trailing currency
text = trimLastChar(text)
return Parse(text)
}
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]
}

View File

@ -0,0 +1,118 @@
package numeric_test
import (
"testing"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
)
type TestCaseMarshalJSON struct {
Value numeric.Numeric
Result string
}
func TestMarshalJSON(t *testing.T) {
t.Parallel()
tests := []TestCaseMarshalJSON{
{numeric.Zero(), `0`},
{numeric.MustParse("1.23"), "1.23"},
{numeric.MustParse("1,24"), "1.24"},
{numeric.MustParse("1"), "1"},
{numeric.MustParse("10"), "10"},
{numeric.MustParse("100"), "100"},
{numeric.MustParse("1000"), "1000"},
{numeric.MustParse("0.1"), "0.1"},
{numeric.MustParse("0.01"), "0.01"},
{numeric.MustParse("0.001"), "0.001"},
{numeric.MustParse("0.0001"), "0.0001"},
{numeric.MustParse("-1"), "-1"},
{numeric.MustParse("-10"), "-10"},
{numeric.MustParse("-100"), "-100"},
{numeric.MustParse("-1000"), "-1000"},
{numeric.MustParse("-0.1"), "-0.1"},
{numeric.MustParse("-0.01"), "-0.01"},
{numeric.MustParse("-0.001"), "-0.001"},
{numeric.MustParse("-0.0001"), "-0.0001"},
{numeric.MustParse("123456789.12345"), "123456789.12345"},
{numeric.MustParse("123456789.12345"), "123456789.12345"},
{numeric.MustParse("-1.23"), "-1.23"},
{numeric.MustParse("-1,24"), "-1.24"},
{numeric.MustParse("-123456789.12345"), "-123456789.12345"},
}
for i := range tests {
test := tests[i]
t.Run(test.Result, func(t *testing.T) {
t.Parallel()
z := test.Value
result, err := z.MarshalJSON()
if err != nil {
t.Error(err)
return
}
if string(result) != test.Result {
t.Errorf("Expected %s, got %s", test.Result, string(result))
return
}
})
}
}
type TestCaseParse struct {
Result numeric.Numeric
Value string
}
func TestParse(t *testing.T) {
t.Parallel()
tests := []TestCaseParse{
{numeric.FromInt64WithExp(0, 0), `0`},
{numeric.FromInt64WithExp(1, 0), `1`},
{numeric.FromInt64WithExp(1, 1), `10`},
{numeric.FromInt64WithExp(1, 2), `100`},
{numeric.FromInt64WithExp(1, 3), `1000`},
{numeric.FromInt64WithExp(1, -1), `0.1`},
{numeric.FromInt64WithExp(1, -2), `0.01`},
{numeric.FromInt64WithExp(1, -3), `0.001`},
{numeric.FromInt64WithExp(1, -4), `0.0001`},
{numeric.FromInt64WithExp(-1, 0), `-1`},
{numeric.FromInt64WithExp(-1, 1), `-10`},
{numeric.FromInt64WithExp(-1, 2), `-100`},
{numeric.FromInt64WithExp(-1, 3), `-1000`},
{numeric.FromInt64WithExp(-1, -1), `-0.1`},
{numeric.FromInt64WithExp(-1, -2), `-0.01`},
{numeric.FromInt64WithExp(-1, -3), `-0.001`},
{numeric.FromInt64WithExp(-1, -4), `-0.0001`},
{numeric.FromInt64WithExp(123, -2), "1.23"},
{numeric.FromInt64WithExp(124, -2), "1,24"},
{numeric.FromInt64WithExp(12345678912345, -5), "123456789.12345"},
{numeric.FromInt64WithExp(0, 0), `-0`},
{numeric.FromInt64WithExp(-1, 0), `-1`},
{numeric.FromInt64WithExp(-1, 1), `-10`},
{numeric.FromInt64WithExp(-1, 2), `-100`},
{numeric.FromInt64WithExp(-123, -2), "-1.23"},
{numeric.FromInt64WithExp(-124, -2), "-1,24"},
{numeric.FromInt64WithExp(-12345678912345, -5), "-123456789.12345"},
}
for i := range tests {
test := tests[i]
t.Run(test.Value, func(t *testing.T) {
t.Parallel()
result, err := numeric.Parse(test.Value)
if err != nil {
t.Error(err)
return
}
if test.Result.Int.Int64() != result.Int.Int64() {
t.Errorf("Expected int %d, got %d", test.Result.Int, result.Int)
return
}
if test.Result.Exp != result.Exp {
t.Errorf("Expected exp %d, got %d", test.Result.Exp, result.Exp)
return
}
})
}
}

View File

@ -58,7 +58,7 @@ func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, e
} }
const searchPayees = `-- name: SearchPayees :many const searchPayees = `-- name: SearchPayees :many
SELECT payees.id, payees.budget_id, payees.name FROM payees SELECT payees.id, payees.budget_id, payees.name, 'payee' as type FROM payees
WHERE payees.budget_id = $1 WHERE payees.budget_id = $1
AND payees.name LIKE $2 AND payees.name LIKE $2
ORDER BY payees.name ORDER BY payees.name
@ -69,16 +69,28 @@ type SearchPayeesParams struct {
Search string Search string
} }
func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]Payee, error) { type SearchPayeesRow struct {
ID uuid.UUID
BudgetID uuid.UUID
Name string
Type interface{}
}
func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]SearchPayeesRow, error) {
rows, err := q.db.QueryContext(ctx, searchPayees, arg.BudgetID, arg.Search) rows, err := q.db.QueryContext(ctx, searchPayees, arg.BudgetID, arg.Search)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Payee var items []SearchPayeesRow
for rows.Next() { for rows.Next() {
var i Payee var i SearchPayeesRow
if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil { if err := rows.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.Type,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)

View File

@ -20,3 +20,16 @@ LEFT JOIN transactions ON transactions.account_id = accounts.id AND transactions
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
GROUP BY accounts.id, accounts.name GROUP BY accounts.id, accounts.name
ORDER BY accounts.name; ORDER BY accounts.name;
-- name: SearchAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
WHERE accounts.budget_id = @budget_id
AND accounts.name LIKE @search
ORDER BY accounts.name;
-- name: UpdateAccount :one
UPDATE accounts
SET name = $1,
on_budget = $2
WHERE accounts.id = $3
RETURNING *;

View File

@ -16,3 +16,10 @@ WHERE categories.id = assignments.category_id AND category_groups.budget_id = @b
SELECT * SELECT *
FROM assignments_by_month FROM assignments_by_month
WHERE assignments_by_month.budget_id = @budget_id; WHERE assignments_by_month.budget_id = @budget_id;
-- name: GetAllAssignments :many
SELECT assignments.date, categories.name as category, category_groups.name as group, assignments.amount
FROM assignments
INNER JOIN categories ON categories.id = assignments.category_id
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = @budget_id;

View File

@ -21,7 +21,8 @@ WHERE category_groups.budget_id = $1
ORDER BY category_groups.name, categories.name; ORDER BY category_groups.name, categories.name;
-- name: SearchCategories :many -- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type
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
AND categories.name LIKE @search AND categories.name LIKE @search

View File

@ -10,7 +10,7 @@ WHERE payees.budget_id = $1
ORDER BY name; ORDER BY name;
-- name: SearchPayees :many -- name: SearchPayees :many
SELECT payees.* FROM payees SELECT payees.*, 'payee' as type FROM payees
WHERE payees.budget_id = @budget_id WHERE payees.budget_id = @budget_id
AND payees.name LIKE @search AND payees.name LIKE @search
ORDER BY payees.name; ORDER BY payees.name;

View File

@ -13,41 +13,50 @@ UPDATE transactions
SET date = $1, SET date = $1,
memo = $2, memo = $2,
amount = $3, amount = $3,
account_id = $4, payee_id = $4,
payee_id = $5, category_id = $5
category_id = $6 WHERE id = $6;
WHERE id = $7;
-- name: DeleteTransaction :exec -- name: DeleteTransaction :exec
DELETE FROM transactions DELETE FROM transactions
WHERE id = $1; WHERE id = $1;
-- name: GetTransactionsForBudget :many -- name: GetAllTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status, 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, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
COALESCE((
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
), '')::text 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
LEFT JOIN categories ON categories.id = transactions.category_id LEFT JOIN categories ON categories.id = transactions.category_id
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
ORDER BY transactions.date DESC ORDER BY transactions.date DESC;
LIMIT 200;
-- name: GetTransactionsForAccount :many -- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee, COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group, COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category, COALESCE(categories.name, '') as category,
( COALESCE((
SELECT CONCAT(otherAccounts.name) SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id AND otherTransactions.id != transactions.id
) as transfer_account ), '')::text 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

View File

@ -7,6 +7,7 @@ import (
"context" "context"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -20,7 +21,7 @@ RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id, s
type CreateTransactionParams struct { type CreateTransactionParams struct {
Date time.Time Date time.Time
Memo string Memo string
Amount Numeric Amount numeric.Numeric
AccountID uuid.UUID AccountID uuid.UUID
PayeeID uuid.NullUUID PayeeID uuid.NullUUID
CategoryID uuid.NullUUID CategoryID uuid.NullUUID
@ -79,6 +80,82 @@ func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
return err return err
} }
const getAllTransactionsForBudget = `-- name: GetAllTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
COALESCE((
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
), '')::text as transfer_account
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id
LEFT JOIN categories ON categories.id = transactions.category_id
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
WHERE accounts.budget_id = $1
ORDER BY transactions.date DESC
`
type GetAllTransactionsForBudgetRow struct {
ID uuid.UUID
Date time.Time
Memo string
Amount numeric.Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
Payee string
CategoryGroup string
Category string
TransferAccount string
}
func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]GetAllTransactionsForBudgetRow, error) {
rows, err := q.db.QueryContext(ctx, getAllTransactionsForBudget, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllTransactionsForBudgetRow
for rows.Next() {
var i GetAllTransactionsForBudgetRow
if err := rows.Scan(
&i.ID,
&i.Date,
&i.Memo,
&i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.PayeeID,
&i.CategoryID,
&i.Payee,
&i.CategoryGroup,
&i.Category,
&i.TransferAccount,
); 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 getTransaction = `-- name: GetTransaction :one const getTransaction = `-- name: GetTransaction :one
SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id, status FROM transactions SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id, status FROM transactions
WHERE id = $1 WHERE id = $1
@ -138,17 +215,17 @@ func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetI
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee, COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group, COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category, COALESCE(categories.name, '') as category,
( COALESCE((
SELECT CONCAT(otherAccounts.name) SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id AND otherTransactions.id != transactions.id
) as transfer_account ), '')::text 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
@ -163,14 +240,16 @@ type GetTransactionsForAccountRow struct {
ID uuid.UUID ID uuid.UUID
Date time.Time Date time.Time
Memo string Memo string
Amount Numeric Amount numeric.Numeric
GroupID uuid.NullUUID GroupID uuid.NullUUID
Status TransactionStatus Status TransactionStatus
Account string Account string
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
Payee string Payee string
CategoryGroup string CategoryGroup string
Category string Category string
TransferAccount interface{} TransferAccount string
} }
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) { func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) {
@ -190,6 +269,8 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
&i.GroupID, &i.GroupID,
&i.Status, &i.Status,
&i.Account, &i.Account,
&i.PayeeID,
&i.CategoryID,
&i.Payee, &i.Payee,
&i.CategoryGroup, &i.CategoryGroup,
&i.Category, &i.Category,
@ -208,82 +289,20 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
return items, nil return items, nil
} }
const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id
LEFT JOIN categories ON categories.id = transactions.category_id
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
WHERE accounts.budget_id = $1
ORDER BY transactions.date DESC
LIMIT 200
`
type GetTransactionsForBudgetRow struct {
ID uuid.UUID
Date time.Time
Memo string
Amount Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string
Payee string
CategoryGroup string
Category string
}
func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]GetTransactionsForBudgetRow, error) {
rows, err := q.db.QueryContext(ctx, getTransactionsForBudget, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTransactionsForBudgetRow
for rows.Next() {
var i GetTransactionsForBudgetRow
if err := rows.Scan(
&i.ID,
&i.Date,
&i.Memo,
&i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.Payee,
&i.CategoryGroup,
&i.Category,
); 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 updateTransaction = `-- name: UpdateTransaction :exec const updateTransaction = `-- name: UpdateTransaction :exec
UPDATE transactions UPDATE transactions
SET date = $1, SET date = $1,
memo = $2, memo = $2,
amount = $3, amount = $3,
account_id = $4, payee_id = $4,
payee_id = $5, category_id = $5
category_id = $6 WHERE id = $6
WHERE id = $7
` `
type UpdateTransactionParams struct { type UpdateTransactionParams struct {
Date time.Time Date time.Time
Memo string Memo string
Amount Numeric Amount numeric.Numeric
AccountID uuid.UUID
PayeeID uuid.NullUUID PayeeID uuid.NullUUID
CategoryID uuid.NullUUID CategoryID uuid.NullUUID
ID uuid.UUID ID uuid.UUID
@ -294,7 +313,6 @@ func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionPa
arg.Date, arg.Date,
arg.Memo, arg.Memo,
arg.Amount, arg.Amount,
arg.AccountID,
arg.PayeeID, arg.PayeeID,
arg.CategoryID, arg.CategoryID,
arg.ID, arg.ID,

144
postgres/ynab-export.go Normal file
View File

@ -0,0 +1,144 @@
package postgres
import (
"context"
"encoding/csv"
"fmt"
"io"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid"
)
type YNABExport struct {
queries *Queries
budgetID uuid.UUID
}
func NewYNABExport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABExport, error) {
return &YNABExport{
queries: queries,
budgetID: budgetID,
}, nil
}
// ImportAssignments expects a TSV-file as exported by YNAB in the following format:
// "Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
// "Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€
//
// Activity and Available are not imported, since they are determined by the transactions and historic assignments.
func (ynab *YNABExport) ExportAssignments(context context.Context, w io.Writer) error {
csv := csv.NewWriter(w)
csv.Comma = '\t'
assignments, err := ynab.queries.GetAllAssignments(context, ynab.budgetID)
if err != nil {
return fmt.Errorf("load assignments: %w", err)
}
count := 0
for _, assignment := range assignments {
row := []string{
assignment.Date.Format("Jan 2006"),
assignment.Group + ": " + assignment.Category,
assignment.Group,
assignment.Category,
assignment.Amount.String() + "€",
numeric.Zero().String() + "€",
numeric.Zero().String() + "€",
}
err := csv.Write(row)
if err != nil {
return fmt.Errorf("write assignment: %w", err)
}
count++
}
csv.Flush()
fmt.Printf("Exported %d assignments\n", count)
return nil
}
// ImportTransactions expects a TSV-file as exported by YNAB in the following format:
// "Account" "Flag" "Date" "Payee" "Category Group/Category" "Category Group" "Category" "Memo" "Outflow" "Inflow" "Cleared"
// "Cash" "" "11.12.2021" "Transfer : Checking" "" "" "" "Brought to bank" 500,00€ 0,00€ "Cleared".
func (ynab *YNABExport) ExportTransactions(context context.Context, w io.Writer) error {
csv := csv.NewWriter(w)
csv.Comma = '\t'
transactions, err := ynab.queries.GetAllTransactionsForBudget(context, ynab.budgetID)
if err != nil {
return fmt.Errorf("load transactions: %w", err)
}
header := []string{
"Account",
"Flag",
"Date",
"Payee",
"Category Group/Category",
"Category Group",
"Category",
"Memo",
"Outflow",
"Inflow",
"Cleared",
}
err = csv.Write(header)
if err != nil {
return fmt.Errorf("write transaction: %w", err)
}
count := 0
for _, transaction := range transactions {
row := GetTransactionRow(transaction)
err := csv.Write(row)
if err != nil {
return fmt.Errorf("write transaction: %w", err)
}
count++
}
csv.Flush()
fmt.Printf("Exported %d transactions\n", count)
return nil
}
func GetTransactionRow(transaction GetAllTransactionsForBudgetRow) []string {
row := []string{
transaction.Account,
"", // Flag
transaction.Date.Format("02.01.2006"),
}
if transaction.TransferAccount != "" {
row = append(row, "Transfer : "+transaction.TransferAccount)
} else {
row = append(row, transaction.Payee)
}
if transaction.CategoryGroup != "" && transaction.Category != "" {
row = append(row,
transaction.CategoryGroup+": "+transaction.Category,
transaction.CategoryGroup,
transaction.Category)
} else {
row = append(row, "", "", "")
}
row = append(row, transaction.Memo)
if transaction.Amount.IsPositive() {
row = append(row, numeric.Zero().String()+"€", transaction.Amount.String()+"€")
} else {
row = append(row, transaction.Amount.String()[1:]+"€", numeric.Zero().String()+"€")
}
return append(row, string(transaction.Status))
}

View File

@ -7,13 +7,12 @@ import (
"io" "io"
"strings" "strings"
"time" "time"
"unicode/utf8"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
type YNABImport struct { type YNABImport struct {
Context context.Context
accounts []Account accounts []Account
payees []Payee payees []Payee
categories []GetCategoriesRow categories []GetCategoriesRow
@ -22,73 +21,70 @@ type YNABImport struct {
budgetID uuid.UUID budgetID uuid.UUID
} }
func NewYNABImport(context context.Context, q *Queries, budgetID uuid.UUID) (*YNABImport, error) { func NewYNABImport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABImport, error) {
accounts, err := q.GetAccounts(context, budgetID) accounts, err := queries.GetAccounts(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
payees, err := q.GetPayees(context, budgetID) payees, err := queries.GetPayees(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
categories, err := q.GetCategories(context, budgetID) categories, err := queries.GetCategories(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
categoryGroups, err := q.GetCategoryGroups(context, budgetID) categoryGroups, err := queries.GetCategoryGroups(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &YNABImport{ return &YNABImport{
Context: context,
accounts: accounts, accounts: accounts,
payees: payees, payees: payees,
categories: categories, categories: categories,
categoryGroups: categoryGroups, categoryGroups: categoryGroups,
queries: q, queries: queries,
budgetID: budgetID, budgetID: budgetID,
}, nil }, nil
} }
// ImportAssignments expects a TSV-file as exported by YNAB in the following format: // ImportAssignments expects a TSV-file as exported by YNAB in the following format:
// "Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available" // "Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
// "Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€ // "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 // Activity and Available are not imported, since they are determined by the transactions and historic assignments.
func (ynab *YNABImport) ImportAssignments(r io.Reader) error { func (ynab *YNABImport) ImportAssignments(context context.Context, r io.Reader) error {
csv := csv.NewReader(r) csv := csv.NewReader(r)
csv.Comma = '\t' csv.Comma = '\t'
csv.LazyQuotes = true csv.LazyQuotes = true
csvData, err := csv.ReadAll() csvData, err := csv.ReadAll()
if err != nil { if err != nil {
return fmt.Errorf("could not read from tsv: %w", err) return fmt.Errorf("read from tsv: %w", err)
} }
count := 0 count := 0
for _, record := range csvData[1:] { for _, record := range csvData[1:] {
dateString := record[0] dateString := record[0]
date, err := time.Parse("Jan 2006", dateString) date, err := time.Parse("Jan 2006", dateString)
if err != nil { if err != nil {
return fmt.Errorf("could not parse date %s: %w", dateString, err) return fmt.Errorf("parse date %s: %w", dateString, err)
} }
categoryGroup, categoryName := record[2], record[3] // also in 1 joined by : categoryGroup, categoryName := record[2], record[3] // also in 1 joined by :
category, err := ynab.GetCategory(categoryGroup, categoryName) category, err := ynab.GetCategory(context, categoryGroup, categoryName)
if err != nil { if err != nil {
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err) return fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err)
} }
amountString := record[4] amountString := record[4]
amount, err := GetAmount(amountString, "0,00€") amount, err := GetAmount(amountString, "0,00€")
if err != nil { if err != nil {
return fmt.Errorf("could not parse amount %s: %w", amountString, err) return fmt.Errorf("parse amount %s: %w", amountString, err)
} }
if amount.Int.Int64() == 0 { if amount.Int.Int64() == 0 {
@ -100,9 +96,9 @@ func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
CategoryID: category.UUID, CategoryID: category.UUID,
Amount: amount, Amount: amount,
} }
_, err = ynab.queries.CreateAssignment(ynab.Context, assignment) _, err = ynab.queries.CreateAssignment(context, assignment)
if err != nil { if err != nil {
return fmt.Errorf("could not save assignment %v: %w", assignment, err) return fmt.Errorf("save assignment %v: %w", assignment, err)
} }
count++ count++
@ -121,25 +117,66 @@ type Transfer struct {
} }
// ImportTransactions expects a TSV-file as exported by YNAB in the following format: // ImportTransactions expects a TSV-file as exported by YNAB in the following format:
// "Account" "Flag" "Date" "Payee" "Category Group/Category" "Category Group" "Category" "Memo" "Outflow" "Inflow" "Cleared"
func (ynab *YNABImport) ImportTransactions(r io.Reader) error { // "Cash" "" "11.12.2021" "Transfer : Checking" "" "" "" "Brought to bank" 500,00€ 0,00€ "Cleared".
func (ynab *YNABImport) ImportTransactions(context context.Context, r io.Reader) error {
csv := csv.NewReader(r) csv := csv.NewReader(r)
csv.Comma = '\t' csv.Comma = '\t'
csv.LazyQuotes = true csv.LazyQuotes = true
csvData, err := csv.ReadAll() csvData, err := csv.ReadAll()
if err != nil { if err != nil {
return fmt.Errorf("could not read from tsv: %w", err) return fmt.Errorf("read from tsv: %w", err)
} }
var openTransfers []Transfer var openTransfers []Transfer
count := 0 count := 0
for _, record := range csvData[1:] { for _, record := range csvData[1:] {
accountName := record[0] transaction, err := ynab.GetTransaction(context, record)
account, err := ynab.GetAccount(accountName)
if err != nil { if err != nil {
return fmt.Errorf("could not get account %s: %w", accountName, err) return err
}
payeeName := record[3]
// Transaction is a transfer
if strings.HasPrefix(payeeName, "Transfer : ") {
err = ynab.ImportTransferTransaction(context, payeeName, transaction.CreateTransactionParams,
&openTransfers, transaction.Account, transaction.Amount)
} else {
err = ynab.ImportRegularTransaction(context, payeeName, transaction.CreateTransactionParams)
}
if err != nil {
return err
}
count++
}
for _, openTransfer := range openTransfers {
fmt.Printf("Saving unmatched transfer from %s to %s on %s over %f as regular transaction\n",
openTransfer.FromAccount, openTransfer.ToAccount, openTransfer.Date, openTransfer.Amount.GetFloat64())
_, err = ynab.queries.CreateTransaction(context, openTransfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err)
}
}
fmt.Printf("Imported %d transactions\n", count)
return nil
}
type NewTransaction struct {
CreateTransactionParams
Account *Account
}
func (ynab *YNABImport) GetTransaction(context context.Context, record []string) (NewTransaction, error) {
accountName := record[0]
account, err := ynab.GetAccount(context, accountName)
if err != nil {
return NewTransaction{}, fmt.Errorf("get account %s: %w", accountName, err)
} }
// flag := record[1] // flag := record[1]
@ -147,13 +184,13 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
dateString := record[2] dateString := record[2]
date, err := time.Parse("02.01.2006", dateString) date, err := time.Parse("02.01.2006", dateString)
if err != nil { if err != nil {
return fmt.Errorf("could not parse date %s: %w", dateString, err) return NewTransaction{}, fmt.Errorf("parse date %s: %w", dateString, err)
} }
categoryGroup, categoryName := record[5], record[6] // also in 4 joined by : categoryGroup, categoryName := record[5], record[6] // also in 4 joined by :
category, err := ynab.GetCategory(categoryGroup, categoryName) category, err := ynab.GetCategory(context, categoryGroup, categoryName)
if err != nil { if err != nil {
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err) return NewTransaction{}, fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err)
} }
memo := record[7] memo := record[7]
@ -162,7 +199,7 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
inflow := record[9] inflow := record[9]
amount, err := GetAmount(inflow, outflow) amount, err := GetAmount(inflow, outflow)
if err != nil { if err != nil {
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err) return NewTransaction{}, fmt.Errorf("parse amount from (%s/%s): %w", inflow, outflow, err)
} }
statusEnum := TransactionStatusUncleared statusEnum := TransactionStatusUncleared
@ -175,33 +212,52 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
case "Uncleared": case "Uncleared":
} }
transaction := CreateTransactionParams{ return NewTransaction{
CreateTransactionParams: CreateTransactionParams{
Date: date, Date: date,
Memo: memo, Memo: memo,
AccountID: account.ID, AccountID: account.ID,
CategoryID: category, CategoryID: category,
Amount: amount, Amount: amount,
Status: statusEnum, Status: statusEnum,
},
Account: account,
}, nil
} }
payeeName := record[3] func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeName string,
if strings.HasPrefix(payeeName, "Transfer : ") { transaction CreateTransactionParams) error {
// Transaction is a transfer to payeeID, err := ynab.GetPayee(context, payeeName)
transferToAccountName := payeeName[11:]
transferToAccount, err := ynab.GetAccount(transferToAccountName)
if err != nil { if err != nil {
return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err) return fmt.Errorf("get payee %s: %w", payeeName, err)
}
transaction.PayeeID = payeeID
_, err = ynab.queries.CreateTransaction(context, transaction)
if err != nil {
return fmt.Errorf("save transaction %v: %w", transaction, err)
}
return nil
}
func (ynab *YNABImport) ImportTransferTransaction(context context.Context, payeeName string,
transaction CreateTransactionParams, openTransfers *[]Transfer,
account *Account, amount numeric.Numeric) error {
transferToAccountName := payeeName[11:]
transferToAccount, err := ynab.GetAccount(context, transferToAccountName)
if err != nil {
return fmt.Errorf("get transfer account %s: %w", transferToAccountName, err)
} }
transfer := Transfer{ transfer := Transfer{
transaction, transaction,
transferToAccount, transferToAccount,
accountName, account.Name,
transferToAccountName, transferToAccountName,
} }
found := false found := false
for i, openTransfer := range openTransfers { for i, openTransfer := range *openTransfers {
if openTransfer.TransferToAccount.ID != transfer.AccountID { if openTransfer.TransferToAccount.ID != transfer.AccountID {
continue continue
} }
@ -213,96 +269,58 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
} }
fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64()) fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
openTransfers[i] = openTransfers[len(openTransfers)-1] transfers := *openTransfers
openTransfers = openTransfers[:len(openTransfers)-1] transfers[i] = transfers[len(transfers)-1]
*openTransfers = transfers[:len(transfers)-1]
found = true found = true
groupID := uuid.New() groupID := uuid.New()
transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true} transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true} openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
_, err = ynab.queries.CreateTransaction(ynab.Context, transfer.CreateTransactionParams) _, err = ynab.queries.CreateTransaction(context, transfer.CreateTransactionParams)
if err != nil { if err != nil {
return fmt.Errorf("could not save transaction %v: %w", transfer.CreateTransactionParams, err) return fmt.Errorf("save transaction %v: %w", transfer.CreateTransactionParams, err)
} }
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams) _, err = ynab.queries.CreateTransaction(context, openTransfer.CreateTransactionParams)
if err != nil { if err != nil {
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err) return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err)
} }
break break
} }
if !found { if !found {
openTransfers = append(openTransfers, transfer) *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 return nil
} }
func trimLastChar(s string) string { func GetAmount(inflow string, outflow string) (numeric.Numeric, error) {
r, size := utf8.DecodeLastRuneInString(s) in, err := numeric.ParseCurrency(inflow)
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 { if err != nil {
return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err) return in, fmt.Errorf("parse inflow: %w", err)
}
if !in.IsZero() {
return in, nil
} }
// if inflow is zero, use outflow // if inflow is zero, use outflow
if num.Int.Int64() != 0 { out, err := numeric.ParseCurrency("-" + outflow)
return num, nil
}
err = num.Set("-" + outflow)
if err != nil { if err != nil {
return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err) return out, fmt.Errorf("parse outflow: %w", err)
} }
return num, nil return out, nil
} }
func (ynab *YNABImport) GetAccount(name string) (*Account, error) { func (ynab *YNABImport) GetAccount(context context.Context, name string) (*Account, error) {
for _, acc := range ynab.accounts { for _, acc := range ynab.accounts {
if acc.Name == name { if acc.Name == name {
return &acc, nil return &acc, nil
} }
} }
account, err := ynab.queries.CreateAccount(ynab.Context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID}) account, err := ynab.queries.CreateAccount(context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -311,7 +329,7 @@ func (ynab *YNABImport) GetAccount(name string) (*Account, error) {
return &account, nil return &account, nil
} }
func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) { func (ynab *YNABImport) GetPayee(context context.Context, name string) (uuid.NullUUID, error) {
if name == "" { if name == "" {
return uuid.NullUUID{}, nil return uuid.NullUUID{}, nil
} }
@ -322,7 +340,7 @@ func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
} }
} }
payee, err := ynab.queries.CreatePayee(ynab.Context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID}) payee, err := ynab.queries.CreatePayee(context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
if err != nil { if err != nil {
return uuid.NullUUID{}, err return uuid.NullUUID{}, err
} }
@ -331,7 +349,7 @@ func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
} }
func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) { func (ynab *YNABImport) GetCategory(context context.Context, group string, name string) (uuid.NullUUID, error) { //nolint
if group == "" || name == "" { if group == "" || name == "" {
return uuid.NullUUID{}, nil return uuid.NullUUID{}, nil
} }
@ -342,32 +360,25 @@ func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, e
} }
} }
for _, categoryGroup := range ynab.categoryGroups { var categoryGroup CategoryGroup
if categoryGroup.Name == group { for _, existingGroup := range ynab.categoryGroups {
createCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID} if existingGroup.Name == group {
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory) categoryGroup = existingGroup
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 categoryGroup.Name == "" {
newGroup := CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID}
var err error
categoryGroup, err = ynab.queries.CreateCategoryGroup(context, newGroup)
if err != nil { if err != nil {
return uuid.NullUUID{}, err return uuid.NullUUID{}, err
} }
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup) ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
}
category, err := ynab.queries.CreateCategory(ynab.Context, CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}) newCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
category, err := ynab.queries.CreateCategory(context, newCategory)
if err != nil { if err != nil {
return uuid.NullUUID{}, err return uuid.NullUUID{}, err
} }

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"net/http" "net/http"
@ -35,3 +35,37 @@ type TransactionsResponse struct {
Account postgres.Account Account postgres.Account
Transactions []postgres.GetTransactionsForAccountRow Transactions []postgres.GetTransactionsForAccountRow
} }
type EditAccountRequest struct {
Name string `json:"name"`
OnBudget bool `json:"onBudget"`
}
func (h *Handler) editAccount(c *gin.Context) {
accountID := c.Param("accountid")
accountUUID, err := uuid.Parse(accountID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
var request EditAccountRequest
err = c.BindJSON(&request)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
updateParams := postgres.UpdateAccountParams{
Name: request.Name,
OnBudget: request.OnBudget,
ID: accountUUID,
}
account, err := h.Service.UpdateAccount(c.Request.Context(), updateParams)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
h.returnBudgetingData(c, account.BudgetID)
}

View File

@ -1,7 +1,8 @@
package http package server
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@ -10,47 +11,54 @@ import (
"git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/jwt" "git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
txdb "github.com/DATA-DOG/go-txdb" txdb "github.com/DATA-DOG/go-txdb"
"github.com/gin-gonic/gin"
) )
func init() { func init() { //nolint:gochecknoinits
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test") txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
} }
func TestListTimezonesHandler(t *testing.T) { func TestRegisterUser(t *testing.T) { //nolint:funlen
db, err := postgres.Connect("pgtx", "example") t.Parallel()
database, err := postgres.Connect("pgtx", "example")
if err != nil { if err != nil {
t.Errorf("could not connect to db: %s", err) fmt.Printf("could not connect to db: %s\n", err)
t.Skip()
return return
} }
h := Handler{ h := Handler{
Service: db, Service: database,
TokenVerifier: &jwt.TokenVerifier{}, TokenVerifier: &jwt.TokenVerifier{
Secret: "this_is_my_demo_secret_for_unit_tests",
},
CredentialsVerifier: &bcrypt.Verifier{}, CredentialsVerifier: &bcrypt.Verifier{},
} }
rr := httptest.NewRecorder() recorder := httptest.NewRecorder()
c, engine := gin.CreateTestContext(rr) context, engine := gin.CreateTestContext(recorder)
h.LoadRoutes(engine) h.LoadRoutes(engine)
t.Run("RegisterUser", func(t *testing.T) { 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"}`)) t.Parallel()
context.Request, err = http.NewRequest(
http.MethodPost,
"/api/v1/user/register",
strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
if err != nil { if err != nil {
t.Errorf("error creating request: %s", err) t.Errorf("error creating request: %s", err)
return return
} }
h.registerPost(c) h.registerPost(context)
if rr.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK) t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
} }
var response LoginResponse var response LoginResponse
err = json.NewDecoder(rr.Body).Decode(&response) err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil { if err != nil {
t.Error(err.Error()) t.Error(err.Error())
t.Error("Error registering") t.Error("Error registering")
@ -61,13 +69,14 @@ func TestListTimezonesHandler(t *testing.T) {
}) })
t.Run("GetTransactions", func(t *testing.T) { t.Run("GetTransactions", func(t *testing.T) {
c.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil) t.Parallel()
if rr.Code != http.StatusOK { context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK) if recorder.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
} }
var response TransactionsResponse var response TransactionsResponse
err = json.NewDecoder(rr.Body).Decode(&response) err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil { if err != nil {
t.Error(err.Error()) t.Error(err.Error())
t.Error("Error retreiving list of transactions.") t.Error("Error retreiving list of transactions.")

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"fmt" "fmt"

72
server/autocomplete.go Normal file
View File

@ -0,0 +1,72 @@
package server
import (
"net/http"
"strings"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) autocompleteCategories(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
query := c.Request.URL.Query().Get("s")
searchParams := postgres.SearchCategoriesParams{
BudgetID: budgetUUID,
Search: "%" + query + "%",
}
categories, err := h.Service.SearchCategories(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, categories)
}
func (h *Handler) autocompletePayee(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
query := c.Request.URL.Query().Get("s")
transferPrefix := "Transfer"
if strings.HasPrefix(query, transferPrefix) {
searchParams := postgres.SearchAccountsParams{
BudgetID: budgetUUID,
Search: "%" + strings.Trim(query[len(transferPrefix):], " \t\n:") + "%",
}
accounts, err := h.Service.SearchAccounts(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, accounts)
} else {
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,10 +1,8 @@
package http package server
import ( import (
"fmt"
"net/http" "net/http"
"git.javil.eu/jacob1123/budgeteer"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -14,18 +12,17 @@ type newBudgetInformation struct {
func (h *Handler) newBudget(c *gin.Context) { func (h *Handler) newBudget(c *gin.Context) {
var newBudget newBudgetInformation var newBudget newBudgetInformation
err := c.BindJSON(&newBudget) if err := c.BindJSON(&newBudget); err != nil {
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, err) c.AbortWithError(http.StatusNotAcceptable, err)
return return
} }
if newBudget.Name == "" { if newBudget.Name == "" {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("Budget name is needed")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budget name is required"})
return return
} }
userID := c.MustGet("token").(budgeteer.Token).GetID() userID := MustGetToken(c).GetID()
budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID) 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)

222
server/budgeting.go Normal file
View File

@ -0,0 +1,222 @@
package server
import (
"fmt"
"net/http"
"strconv"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
}
func getFirstOfMonthTime(date time.Time) time.Time {
var monthM time.Month
year, monthM, _ := date.Date()
month := int(monthM)
return getFirstOfMonth(year, month, date.Location())
}
type CategoryWithBalance struct {
*postgres.GetCategoriesRow
Available numeric.Numeric
AvailableLastMonth numeric.Numeric
Activity numeric.Numeric
Assigned numeric.Numeric
}
func NewCategoryWithBalance(category *postgres.GetCategoriesRow) CategoryWithBalance {
return CategoryWithBalance{
GetCategoriesRow: category,
Available: numeric.Zero(),
AvailableLastMonth: numeric.Zero(),
Activity: numeric.Zero(),
Assigned: numeric.Zero(),
}
}
func getDate(c *gin.Context) (time.Time, error) {
var year, month int
yearString := c.Param("year")
monthString := c.Param("month")
if yearString == "" && monthString == "" {
return getFirstOfMonthTime(time.Now()), nil
}
year, err := strconv.Atoi(yearString)
if err != nil {
return time.Time{}, fmt.Errorf("parse year: %w", err)
}
month, err = strconv.Atoi(monthString)
if err != nil {
return time.Time{}, fmt.Errorf("parse month: %w", err)
}
return getFirstOfMonth(year, month, time.Now().Location()), nil
}
func (h *Handler) budgetingForMonth(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
firstOfMonth, err := getDate(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{fmt.Sprintf("error loading balances: %s", err)})
return
}
categoriesWithBalance, moneyUsed := h.calculateBalances(
budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
availableBalance := h.getAvailableBalance(categories, budget, moneyUsed, cumultativeBalances, firstOfNextMonth)
data := struct {
Categories []CategoryWithBalance
AvailableBalance numeric.Numeric
}{categoriesWithBalance, availableBalance}
c.JSON(http.StatusOK, data)
}
func (*Handler) getAvailableBalance(categories []postgres.GetCategoriesRow, budget postgres.Budget,
moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
firstOfNextMonth time.Time) numeric.Numeric {
availableBalance := numeric.Zero()
for _, cat := range categories {
if cat.ID != budget.IncomeCategoryID {
continue
}
availableBalance = moneyUsed
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
continue
}
if !bal.Date.Before(firstOfNextMonth) {
continue
}
availableBalance = availableBalance.Add(bal.Transactions)
}
}
return availableBalance
}
type BudgetingResponse struct {
Accounts []postgres.GetAccountsWithBalanceRow
Budget postgres.Budget
}
func (h *Handler) budgeting(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
h.returnBudgetingData(c, budgetUUID)
}
func (h *Handler) returnBudgetingData(c *gin.Context, budgetUUID uuid.UUID) {
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
data := BudgetingResponse{accounts, budget}
c.JSON(http.StatusOK, data)
}
func (h *Handler) calculateBalances(budget postgres.Budget,
firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow,
cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, numeric.Numeric) {
categoriesWithBalance := []CategoryWithBalance{}
moneyUsed := numeric.Zero()
for i := range categories {
cat := &categories[i]
// do not show hidden categories
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
firstOfNextMonth, &moneyUsed, firstOfMonth, budget)
if cat.ID == budget.IncomeCategoryID {
continue
}
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
}
return categoriesWithBalance, moneyUsed
}
func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
cumultativeBalances []postgres.GetCumultativeBalancesRow, firstOfNextMonth time.Time,
moneyUsed *numeric.Numeric, firstOfMonth time.Time, budget postgres.Budget) CategoryWithBalance {
categoryWithBalance := NewCategoryWithBalance(cat)
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
continue
}
// skip everything in the future
if !bal.Date.Before(firstOfNextMonth) {
continue
}
*moneyUsed = moneyUsed.Sub(bal.Assignments)
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments)
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions)
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
*moneyUsed = moneyUsed.Add(categoryWithBalance.Available)
categoryWithBalance.Available = numeric.Zero()
}
if bal.Date.Before(firstOfMonth) {
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
} else if bal.Date.Before(firstOfNextMonth) {
categoryWithBalance.Activity = bal.Transactions
categoryWithBalance.Assigned = bal.Assignments
}
}
return categoryWithBalance
}

View File

@ -1,15 +1,14 @@
package http package server
import ( import (
"net/http" "net/http"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func (h *Handler) dashboard(c *gin.Context) { func (h *Handler) dashboard(c *gin.Context) {
userID := c.MustGet("token").(budgeteer.Token).GetID() userID := MustGetToken(c).GetID()
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
if err != nil { if err != nil {
return return

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"errors" "errors"
@ -11,12 +11,10 @@ import (
"git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/web"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// Handler handles incoming requests // Handler handles incoming requests.
type Handler struct { type Handler struct {
Service *postgres.Database Service *postgres.Database
TokenVerifier budgeteer.TokenVerifier TokenVerifier budgeteer.TokenVerifier
@ -24,25 +22,22 @@ type Handler struct {
StaticFS http.FileSystem StaticFS http.FileSystem
} }
const ( // Serve starts the http server.
expiration = 72
)
// Serve starts the http server
func (h *Handler) Serve() { func (h *Handler) Serve() {
router := gin.Default() router := gin.Default()
h.LoadRoutes(router) h.LoadRoutes(router)
router.Run(":1323")
if err := router.Run(":1323"); err != nil {
panic(err)
}
} }
// LoadRoutes initializes all the routes type ErrorResponse struct {
Message string
}
// LoadRoutes initializes all the routes.
func (h *Handler) LoadRoutes(router *gin.Engine) { func (h *Handler) LoadRoutes(router *gin.Engine) {
static, err := fs.Sub(web.Static, "dist")
if err != nil {
panic("couldn't open static files")
}
h.StaticFS = http.FS(static)
router.Use(enableCachingForStaticFiles()) router.Use(enableCachingForStaticFiles())
router.NoRoute(h.ServeStatic) router.NoRoute(h.ServeStatic)
@ -65,6 +60,7 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
authenticated.Use(h.verifyLoginWithForbidden) authenticated.Use(h.verifyLoginWithForbidden)
authenticated.GET("/dashboard", h.dashboard) authenticated.GET("/dashboard", h.dashboard)
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount) authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
authenticated.POST("/account/:accountid", h.editAccount)
authenticated.GET("/admin/clear-database", h.clearDatabase) authenticated.GET("/admin/clear-database", h.clearDatabase)
authenticated.GET("/budget/:budgetid", h.budgeting) authenticated.GET("/budget/:budgetid", h.budgeting)
authenticated.GET("/budget/:budgetid/:year/:month", h.budgetingForMonth) authenticated.GET("/budget/:budgetid/:year/:month", h.budgetingForMonth)
@ -72,6 +68,8 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
authenticated.GET("/budget/:budgetid/autocomplete/categories", h.autocompleteCategories) authenticated.GET("/budget/:budgetid/autocomplete/categories", h.autocompleteCategories)
authenticated.DELETE("/budget/:budgetid", h.deleteBudget) authenticated.DELETE("/budget/:budgetid", h.deleteBudget)
authenticated.POST("/budget/:budgetid/import/ynab", h.importYNAB) authenticated.POST("/budget/:budgetid/import/ynab", h.importYNAB)
authenticated.POST("/budget/:budgetid/export/ynab/transactions", h.exportYNABTransactions)
authenticated.POST("/budget/:budgetid/export/ynab/assignments", h.exportYNABAssignments)
authenticated.POST("/budget/:budgetid/settings/clear", h.clearBudget) authenticated.POST("/budget/:budgetid/settings/clear", h.clearBudget)
budget := authenticated.Group("/budget") budget := authenticated.Group("/budget")
@ -81,6 +79,7 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
transaction.POST("/new", h.newTransaction) transaction.POST("/new", h.newTransaction)
transaction.POST("/:transactionid", h.newTransaction) transaction.POST("/:transactionid", h.newTransaction)
} }
func (h *Handler) ServeStatic(c *gin.Context) { func (h *Handler) ServeStatic(c *gin.Context) {
h.ServeStaticFile(c, c.Request.URL.Path) h.ServeStaticFile(c, c.Request.URL.Path)
} }
@ -108,7 +107,11 @@ func (h *Handler) ServeStaticFile(c *gin.Context, fullPath string) {
return return
} }
http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file.(io.ReadSeeker)) if file, ok := file.(io.ReadSeeker); ok {
http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file)
} else {
panic("File does not implement ReadSeeker")
}
} }
func enableCachingForStaticFiles() gin.HandlerFunc { func enableCachingForStaticFiles() gin.HandlerFunc {

View File

@ -1,29 +1,36 @@
package http package server
import ( import (
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"time" "time"
) )
type JSONDate time.Time type JSONDate time.Time
// Implement Marshaler and Unmarshaler interface // UnmarshalJSON parses the JSONDate from a JSON input.
func (j *JSONDate) UnmarshalJSON(b []byte) error { func (j *JSONDate) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"") s := strings.Trim(string(b), "\"")
t, err := time.Parse("2006-01-02", s) t, err := time.Parse("2006-01-02", s)
if err != nil { if err != nil {
return err return fmt.Errorf("parse date: %w", err)
} }
*j = JSONDate(t) *j = JSONDate(t)
return nil return nil
} }
// MarshalJSON converts the JSONDate to a JSON in ISO format.
func (j JSONDate) MarshalJSON() ([]byte, error) { func (j JSONDate) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Time(j)) result, err := json.Marshal(time.Time(j))
if err != nil {
return nil, fmt.Errorf("marshal date: %w", err)
} }
// Maybe a Format function for printing your date return result, nil
}
// Format formats the time using the regular time.Time mechanics..
func (j JSONDate) Format(s string) string { func (j JSONDate) Format(s string) string {
t := time.Time(j) t := time.Time(j)
return t.Format(s) return t.Format(s)

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"context" "context"
@ -8,18 +8,34 @@ import (
"git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) { const (
tokenString := c.GetHeader("Authorization") HeaderName = "Authorization"
if len(tokenString) < 8 { Bearer = "Bearer "
return nil, fmt.Errorf("no authorization header supplied") ParamName = "token"
)
func MustGetToken(c *gin.Context) budgeteer.Token { //nolint:ireturn
token := c.MustGet(ParamName)
if token, ok := token.(budgeteer.Token); ok {
return token
}
panic("Token is not a valid Token")
}
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, *ErrorResponse) { //nolint:ireturn
tokenString := c.GetHeader(HeaderName)
if len(tokenString) <= len(Bearer) {
return nil, &ErrorResponse{"no authorization header supplied"}
} }
tokenString = tokenString[7:] tokenString = tokenString[7:]
token, err := h.TokenVerifier.VerifyToken(tokenString) token, err := h.TokenVerifier.VerifyToken(tokenString)
if err != nil { if err != nil {
return nil, fmt.Errorf("verify token '%s': %w", tokenString, err) return nil, &ErrorResponse{fmt.Sprintf("verify token '%s': %s", tokenString, err)}
} }
return token, nil return token, nil
@ -29,11 +45,11 @@ func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
token, err := h.verifyLogin(c) token, err := h.verifyLogin(c)
if err != nil { if err != nil {
// c.Header("WWW-Authenticate", "Bearer") // c.Header("WWW-Authenticate", "Bearer")
c.AbortWithError(http.StatusForbidden, err) c.AbortWithStatusJSON(http.StatusForbidden, err)
return return
} }
c.Set("token", token) c.Set(ParamName, token)
c.Next() c.Next()
} }
@ -45,7 +61,7 @@ func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
return return
} }
c.Set("token", token) c.Set(ParamName, token)
c.Next() c.Next()
} }
@ -72,19 +88,19 @@ func (h *Handler) loginPost(c *gin.Context) {
return return
} }
t, err := h.TokenVerifier.CreateToken(&user) token, err := h.TokenVerifier.CreateToken(&user)
if err != nil { if err != nil {
c.AbortWithError(http.StatusUnauthorized, err) c.AbortWithError(http.StatusUnauthorized, err)
} }
go h.Service.UpdateLastLogin(context.Background(), user.ID) go h.UpdateLastLogin(user.ID)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil { if err != nil {
return return
} }
c.JSON(http.StatusOK, LoginResponse{t, user, budgets}) c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
} }
type LoginResponse struct { type LoginResponse struct {
@ -101,16 +117,20 @@ type registerInformation struct {
func (h *Handler) registerPost(c *gin.Context) { func (h *Handler) registerPost(c *gin.Context) {
var register registerInformation var register registerInformation
c.BindJSON(&register) err := c.BindJSON(&register)
if err != nil {
if register.Email == "" || register.Password == "" || register.Name == "" { c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"error parsing body"})
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("e-mail, password and name are required"))
return return
} }
_, err := h.Service.GetUserByUsername(c.Request.Context(), register.Email) if register.Email == "" || register.Password == "" || register.Name == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"e-mail, password and name are required"})
return
}
_, err = h.Service.GetUserByUsername(c.Request.Context(), register.Email)
if err == nil { if err == nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("email is already taken")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"email is already taken"})
return return
} }
@ -130,17 +150,24 @@ func (h *Handler) registerPost(c *gin.Context) {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
} }
t, err := h.TokenVerifier.CreateToken(&user) token, err := h.TokenVerifier.CreateToken(&user)
if err != nil { if err != nil {
c.AbortWithError(http.StatusUnauthorized, err) c.AbortWithError(http.StatusUnauthorized, err)
} }
go h.Service.UpdateLastLogin(context.Background(), user.ID) go h.UpdateLastLogin(user.ID)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil { if err != nil {
return return
} }
c.JSON(http.StatusOK, LoginResponse{t, user, budgets}) c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
}
func (h *Handler) UpdateLastLogin(userID uuid.UUID) {
_, err := h.Service.UpdateLastLogin(context.Background(), userID)
if err != nil {
fmt.Printf("Error updating last login: %s", err)
}
} }

141
server/transaction.go Normal file
View File

@ -0,0 +1,141 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NewTransactionPayload struct {
Date JSONDate `json:"date"`
Payee struct {
ID uuid.NullUUID
Name string
Type string
} `json:"payee"`
CategoryID uuid.NullUUID `json:"categoryId"`
Memo string `json:"memo"`
Amount string `json:"amount"`
BudgetID uuid.UUID `json:"budgetId"`
AccountID uuid.UUID `json:"accountId"`
State string `json:"state"`
}
func (h *Handler) newTransaction(c *gin.Context) {
var payload NewTransactionPayload
err := c.BindJSON(&payload)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
amount, err := numeric.Parse(payload.Amount)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("amount: %w", err))
return
}
transactionID := c.Param("transactionid")
if transactionID != "" {
h.UpdateTransaction(payload, amount, transactionID, c)
return
}
newTransaction := postgres.CreateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
Status: postgres.TransactionStatus(payload.State),
CategoryID: payload.CategoryID,
AccountID: payload.AccountID,
}
if payload.Payee.Type == "account" {
err := h.CreateTransferForOtherAccount(newTransaction, amount, payload, c)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
} else {
payeeID, err := GetPayeeID(c.Request.Context(), payload, h)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create payee: %w", err))
}
newTransaction.PayeeID = payeeID
}
transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
return
}
c.JSON(http.StatusOK, transaction)
}
func (h *Handler) UpdateTransaction(payload NewTransactionPayload, amount numeric.Numeric, transactionID string, c *gin.Context) {
transactionUUID := uuid.MustParse(transactionID)
if amount.IsZero() {
err := h.Service.DeleteTransaction(c.Request.Context(), transactionUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err))
}
return
}
editTransaction := postgres.UpdateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
PayeeID: payload.Payee.ID,
CategoryID: payload.CategoryID,
ID: transactionUUID,
}
err := h.Service.UpdateTransaction(c.Request.Context(), editTransaction)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("edit transaction: %w", err))
}
}
func (h *Handler) CreateTransferForOtherAccount(newTransaction postgres.CreateTransactionParams, amount numeric.Numeric, payload NewTransactionPayload, c *gin.Context) error {
newTransaction.GroupID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
newTransaction.Amount = amount.Neg()
newTransaction.AccountID = payload.Payee.ID.UUID
// transfer does not need category. Either it's account is off-budget or no category was supplied.
newTransaction.CategoryID = uuid.NullUUID{}
_, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if err != nil {
return fmt.Errorf("create transfer transaction: %w", err)
}
return nil
}
func GetPayeeID(context context.Context, payload NewTransactionPayload, h *Handler) (uuid.NullUUID, error) {
payeeID := payload.Payee.ID
if payeeID.Valid {
return payeeID, nil
}
if payload.Payee.Name == "" {
return uuid.NullUUID{}, nil
}
newPayee := postgres.CreatePayeeParams{
Name: payload.Payee.Name,
BudgetID: payload.BudgetID,
}
payee, err := h.Service.CreatePayee(context, newPayee)
if err != nil {
return uuid.NullUUID{}, fmt.Errorf("create payee: %w", err)
}
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
}

117
server/ynab-import.go Normal file
View File

@ -0,0 +1,117 @@
package server
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) importYNAB(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactionsFile, err := c.FormFile("transactions")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactions, err := transactionsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportTransactions(c.Request.Context(), transactions)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignmentsFile, err := c.FormFile("assignments")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignments, err := assignmentsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportAssignments(c.Request.Context(), assignments)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) exportYNABTransactions(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABExport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ExportTransactions(c.Request.Context(), c.Writer)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) exportYNABAssignments(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABExport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ExportAssignments(c.Request.Context(), c.Writer)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}

View File

@ -7,9 +7,11 @@ packages:
queries: "postgres/queries/" queries: "postgres/queries/"
overrides: overrides:
- go_type: - go_type:
type: "Numeric" import: "git.javil.eu/jacob1123/budgeteer/postgres/numeric"
type: Numeric
db_type: "pg_catalog.numeric" db_type: "pg_catalog.numeric"
- go_type: - go_type:
type: "Numeric" import: "git.javil.eu/jacob1123/budgeteer/postgres/numeric"
type: Numeric
db_type: "pg_catalog.numeric" db_type: "pg_catalog.numeric"
nullable: true nullable: true

View File

@ -5,7 +5,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// Token contains data that authenticates a user // Token contains data that authenticates a user.
type Token interface { type Token interface {
GetUsername() string GetUsername() string
GetName() string GetName() string
@ -13,7 +13,7 @@ type Token interface {
GetID() uuid.UUID GetID() uuid.UUID
} }
// TokenVerifier verifies a Token // TokenVerifier verifies a Token.
type TokenVerifier interface { type TokenVerifier interface {
VerifyToken(string) (Token, error) VerifyToken(string) (Token, error)
CreateToken(*postgres.User) (string, error) CreateToken(*postgres.User) (string, error)

View File

@ -11,6 +11,7 @@
"@mdi/font": "5.9.55", "@mdi/font": "5.9.55",
"@vueuse/core": "^7.6.1", "@vueuse/core": "^7.6.1",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"file-saver": "^2.0.5",
"pinia": "^2.0.11", "pinia": "^2.0.11",
"postcss": "^8.4.6", "postcss": "^8.4.6",
"tailwindcss": "^3.0.18", "tailwindcss": "^3.0.18",
@ -18,6 +19,7 @@
"vue-router": "^4.0.12" "vue-router": "^4.0.12"
}, },
"devDependencies": { "devDependencies": {
"@types/file-saver": "^2.0.5",
"@vitejs/plugin-vue": "^2.0.0", "@vitejs/plugin-vue": "^2.0.0",
"@vue/cli-plugin-babel": "5.0.0-beta.7", "@vue/cli-plugin-babel": "5.0.0-beta.7",
"@vue/cli-plugin-typescript": "~4.5.0", "@vue/cli-plugin-typescript": "~4.5.0",

View File

@ -1,101 +1,96 @@
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, PropType } from "vue" import { ref, watch } from "vue"
import { GET } from "../api"; import { GET } from "../api";
import { useBudgetsStore } from "../stores/budget"; import { useBudgetsStore } from "../stores/budget";
export interface Suggestion { export interface Suggestion {
ID: string ID: string
Name: string Name: string
Type: string
} }
interface Data { const props = defineProps<{
Selected: Suggestion | undefined text: String,
SearchQuery: String id: String | undefined,
Suggestions: Suggestion[] model: String,
} type?: string | undefined,
}>();
export default defineComponent({ const SearchQuery = ref(props.text || "");
data() { const Suggestions = ref<Array<Suggestion>>([]);
return { const emit = defineEmits(["update:id", "update:text", "update:type"]);
Selected: undefined, watch(SearchQuery, () => {
SearchQuery: this.modelValue || "", load(SearchQuery.value);
Suggestions: new Array<Suggestion>(), });
} as Data function load(text: String) {
}, emit('update:id', null);
props: { emit('update:text', text);
modelValue: Object as PropType<Suggestion>, emit('update:type', undefined);
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 == "") { if (text == "") {
this.$data.Suggestions = []; Suggestions.value = [];
return; return;
} }
const budgetStore = useBudgetsStore(); const budgetStore = useBudgetsStore();
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + this.type + "?s=" + text) GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.model + "?s=" + text)
.then(x => x.json()) .then(x => x.json())
.then(x => { .then(x => {
let suggestions = x || []; let suggestions = x || [];
if (suggestions.length > 10) { if (suggestions.length > 10) {
suggestions = suggestions.slice(0, 10); suggestions = suggestions.slice(0, 10);
} }
this.$data.Suggestions = suggestions; Suggestions.value = suggestions;
}); });
}, };
keypress(e : KeyboardEvent) { function keypress(e: KeyboardEvent) {
console.log(e.key);
if (e.key == "Enter") { if (e.key == "Enter") {
const selected = this.$data.Suggestions[0]; const selected = Suggestions.value[0];
this.selectElement(selected); selectElement(selected);
const el = (<HTMLInputElement>e.target); const el = (<HTMLInputElement>e.target);
const inputElements = Array.from(el.ownerDocument.querySelectorAll('input:not([disabled]):not([readonly])')); const inputElements = Array.from(el.ownerDocument.querySelectorAll('input:not([disabled]):not([readonly])'));
const currentIndex = inputElements.indexOf(el); const currentIndex = inputElements.indexOf(el);
const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0]; const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0];
(<HTMLInputElement>nextElement).focus(); (<HTMLInputElement>nextElement).focus();
} }
}, };
selectElement(element : Suggestion) { function selectElement(element: Suggestion) {
this.$data.Selected = element; emit('update:id', element.ID);
this.$data.Suggestions = []; emit('update:text', element.Name);
this.$emit('update:modelValue', element); emit('update:type', element.Type);
}, Suggestions.value = [];
select(e : MouseEvent) { };
function select(e: MouseEvent) {
const target = (<HTMLInputElement>e.target); const target = (<HTMLInputElement>e.target);
const valueAttribute = target.attributes.getNamedItem("value"); const valueAttribute = target.attributes.getNamedItem("value");
let selectedID = ""; let selectedID = "";
if (valueAttribute != null) if (valueAttribute != null)
selectedID = valueAttribute.value; selectedID = valueAttribute.value;
const selected = this.$data.Suggestions.filter(x => x.ID == selectedID)[0]; const selected = Suggestions.value.filter(x => x.ID == selectedID)[0];
this.selectElement(selected); selectElement(selected);
}, };
clear() { function clear() {
this.$data.Selected = undefined; emit('update:id', null);
this.$emit('update:modelValue', {ID: null, Name: this.$data.SearchQuery}); emit('update:text', SearchQuery.value);
} emit('update:type', undefined);
} };
})
</script> </script>
<template> <template>
<div> <div>
<input class="border-b-2 border-black" @keypress="keypress" v-if="Selected == undefined" v-model="SearchQuery" /> <input
<span @click="clear" v-if="Selected != undefined" class="bg-gray-300">{{Selected.Name}}</span> class="border-b-2 border-black"
@keypress="keypress"
v-if="id == undefined"
v-model="SearchQuery"
/>
<span @click="clear" v-if="id != undefined" class="bg-gray-300">{{ text }}</span>
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2"> <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"> <span
{{suggestion.Name}} v-for="suggestion in Suggestions"
</span> class="block"
@click="select"
:value="suggestion.ID"
>{{ suggestion.Name }}</span>
</div> </div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,10 @@
<script lang="ts" setup>
</script>
<template>
<button
class="px-4 py-2 text-base font-medium rounded-md shadow-sm focus:outline-none focus:ring-2"
>
<slot></slot>
</button>
</template>

View File

@ -1,9 +1,4 @@
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from "vue";
export default defineComponent({
})
</script> </script>
<template> <template>

View File

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

View File

@ -0,0 +1,33 @@
<script lang="ts" setup>
const props = defineProps(["modelValue"]);
const emit = defineEmits(['update:modelValue']);
function dateToYYYYMMDD(d: Date) : string {
// alternative implementations in https://stackoverflow.com/q/23593052/1850609
//return new Date(d.getTime() - (d.getTimezoneOffset() * 60 * 1000)).toISOString().split('T')[0];
return d.toISOString().split('T')[0];
}
function updateValue(event: Event) {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.valueAsDate);
}
function selectAll(event: FocusEvent) {
// Workaround for Safari bug
// http://stackoverflow.com/questions/1269722/selecting-text-on-focus-using-jquery-not-working-in-safari-and-chrome
setTimeout(function () {
const target = event.target as HTMLInputElement;
target.select()
}, 0)
}
</script>
<template>
<input
type="date"
ref="input"
v-bind:value="dateToYYYYMMDD(modelValue)"
@input="updateValue"
@focus="selectAll"
/>
</template>

View File

@ -0,0 +1,58 @@
<script lang="ts" setup>
import Card from '../components/Card.vue';
import { ref } from "vue";
const props = defineProps<{
buttonText: string,
}>();
const emit = defineEmits<{
(e: 'submit'): void,
(e: 'open'): void,
}>();
const visible = ref(false);
function closeDialog() {
visible.value = false;
};
function openDialog() {
emit("open");
visible.value = true;
};
function submitDialog() {
visible.value = false;
emit("submit");
}
</script>
<template>
<button @click="openDialog">
<slot name="placeholder">
<Card>
<p class="w-24 text-center text-6xl">+</p>
<span class="text-lg" dark>{{ buttonText }}</span>
</Card>
</slot>
</button>
<div
v-if="visible"
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full"
>
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3 text-center">
<h3 class="mt-3 text-lg leading-6 font-medium text-gray-900">{{ buttonText }}</h3>
<slot></slot>
<div class="grid grid-cols-2 gap-6">
<button
@click="closeDialog"
class="px-4 py-2 bg-red-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300"
>Close</button>
<button
@click="submitDialog"
class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300"
>Save</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,60 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import Autocomplete from './Autocomplete.vue'
import { useAccountStore } from '../stores/budget-account'
import DateInput from "./DateInput.vue";
const props = defineProps<{
transactionid: string
}>()
const accountStore = useAccountStore();
const TX = accountStore.Transactions.get(props.transactionid)!;
const payeeType = ref<string|undefined>(undefined);
const payload = computed(() => JSON.stringify({
date: TX.Date.toISOString().split("T")[0],
payee: {
Name: TX.Payee,
ID: TX.PayeeID,
Type: payeeType.value,
},
categoryId: TX.CategoryID,
memo: TX.Memo,
amount: TX.Amount.toString(),
state: "Uncleared"
}));
function saveTransaction(e: MouseEvent) {
e.preventDefault();
accountStore.editTransaction(TX.ID, payload.value);
}
</script>
<template>
<tr>
<td style="width: 90px;" class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" />
</td>
<td style="max-width: 150px;">
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
</td>
<td style="max-width: 200px;">
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
</td>
<td>
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.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="TX.Amount"
/>
</td>
<td style="width: 20px;">
<input type="submit" @click="saveTransaction" value="Save" />
</td>
<td style="width: 20px;"></td>
</tr>
</template>

View File

@ -0,0 +1,77 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
import { Transaction, useAccountStore } from '../stores/budget-account'
import DateInput from "./DateInput.vue";
const props = defineProps<{
budgetid: string
accountid: string
}>()
const TX = ref<Transaction>({
Date: new Date(),
Memo: "",
Amount: 0,
Payee: "",
PayeeID: undefined,
Category: "",
CategoryID: undefined,
CategoryGroup: "",
GroupID: "",
ID: "",
Status: "Uncleared",
TransferAccount: "",
});
const payeeType = ref<string|undefined>(undefined);
const payload = computed(() => JSON.stringify({
budgetId: props.budgetid,
accountId: props.accountid,
date: TX.value.Date.toISOString().split("T")[0],
payee: {
Name: TX.value.Payee,
ID: TX.value.PayeeID,
Type: payeeType.value,
},
categoryId: TX.value.CategoryID,
memo: TX.value.Memo,
amount: TX.value.Amount.toString(),
state: "Uncleared"
}));
const accountStore = useAccountStore();
function saveTransaction(e: MouseEvent) {
e.preventDefault();
accountStore.saveTransaction(payload.value);
}
</script>
<template>
<tr>
<td style="width: 90px;" class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" />
</td>
<td style="max-width: 150px;">
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
</td>
<td style="max-width: 200px;">
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
</td>
<td>
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.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="TX.Amount"
/>
</td>
<td style="width: 20px;">
<input type="submit" @click="saveTransaction" value="Save" />
</td>
<td style="width: 20px;"></td>
</tr>
</template>

View File

@ -1,25 +1,28 @@
<script lang="ts"> <script lang="ts" setup>
import { mapState } from "pinia"; import { computed, ref } from "vue";
import { defineComponent } from "vue";
import { useBudgetsStore } from "../stores/budget"; import { useBudgetsStore } from "../stores/budget";
import { Transaction } from "../stores/budget-account";
import Currency from "./Currency.vue"; import Currency from "./Currency.vue";
import TransactionEditRow from "./TransactionEditRow.vue";
import { formatDate } from "../date";
export default defineComponent({ const props = defineProps<{
props: [ "transaction", "index" ], transaction: Transaction,
components: { Currency }, index: number,
computed: { }>();
...mapState(useBudgetsStore, ["CurrentBudgetID"])
} const edit = ref(false);
})
const CurrentBudgetID = computed(()=> useBudgetsStore().CurrentBudgetID);
</script> </script>
<template> <template>
<tr class="{{transaction.Date.After now ? 'future' : ''}}" <tr v-if="!edit" class="{{new Date(transaction.Date) > new Date() ? 'future' : ''}}"
:class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']"> :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' : '']">--> <!--: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>{{ formatDate(transaction.Date) }}</td>
<td style="max-width: 150px;">{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td> <td>{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td>
<td style="max-width: 200px;"> <td>
{{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }} {{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }}
</td> </td>
<td> <td>
@ -30,11 +33,12 @@ export default defineComponent({
<td> <td>
<Currency class="block" :value="transaction.Amount" /> <Currency class="block" :value="transaction.Amount" />
</td> </td>
<td style="width: 20px;"> <td>
{{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }} {{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }}
</td> </td>
<td style="width: 20px;">{{ transaction.GroupID ? "☀" : "" }}</td> <td class="text-right">{{ transaction.GroupID ? "☀" : "" }}<a @click="edit = true;"></a></td>
</tr> </tr>
<TransactionEditRow v-if="edit" :transactionid="transaction.ID" />
</template> </template>
<style> <style>

7
web/src/date.ts Normal file
View File

@ -0,0 +1,7 @@
export function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, { // you can use undefined as first argument
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}

View File

@ -0,0 +1,44 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import Modal from '../components/Modal.vue';
import { useAccountStore } from '../stores/budget-account';
const accountStore = useAccountStore();
const CurrentAccount = computed(() => accountStore.CurrentAccount);
const accountName = ref("");
const accountOnBudget = ref(true);
function editAccount(e : any) {
accountStore.EditAccount(CurrentAccount.value?.ID ?? "", accountName.value, accountOnBudget.value);
}
function openEditAccount(e : any) {
accountName.value = CurrentAccount.value?.Name ?? "";
accountOnBudget.value = CurrentAccount.value?.OnBudget ?? true;
}
</script>
<template>
<Modal button-text="Edit Account" @open="openEditAccount" @submit="editAccount">
<template v-slot:placeholder></template>
<div class="mt-2 px-7 py-3">
<input
class="border-2"
type="text"
v-model="accountName"
placeholder="Account name"
required
/>
</div>
<div class="mt-2 px-7 py-3">
<input
class="border-2"
type="checkbox"
v-model="accountOnBudget"
required
/>
<label>On Budget</label>
</div>
</Modal>
</template>

View File

@ -1,45 +1,18 @@
<script lang="ts"> <script lang="ts" setup>
import Card from '../components/Card.vue'; import Modal from '../components/Modal.vue';
import { defineComponent } from "vue"; import { ref } from "vue";
import { useBudgetsStore } from '../stores/budget'; import { useBudgetsStore } from '../stores/budget';
export default defineComponent({ const budgetName = ref("");
data() { function saveBudget() {
return { useBudgetsStore().NewBudget(budgetName.value);
dialog: false, };
budgetName: ""
}
},
components: { Card },
methods: {
saveBudget() {
useBudgetsStore().NewBudget(this.$data.budgetName);
this.$data.dialog = false;
},
newBudget() {
this.$data.dialog = true;
}
}
})
</script> </script>
<template> <template>
<Card> <Modal button-text="New Budget" @submit="saveBudget">
<p class="w-24 text-center text-6xl">+</p> <div class="mt-2 px-7 py-3">
<button class="text-lg" dark @click="newBudget">New Budget</button> <input class="border-2" type="text" v-model="budgetName" placeholder="Budget name" required />
</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> </div>
</Modal>
</template> </template>

View File

@ -1,44 +1,26 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from "vue" import { computed, ref } from "vue"
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
import Currency from "../components/Currency.vue"; import Currency from "../components/Currency.vue";
import TransactionRow from "../components/TransactionRow.vue"; import TransactionRow from "../components/TransactionRow.vue";
import { POST } from "../api"; import TransactionInputRow from "../components/TransactionInputRow.vue";
import { useAccountStore } from "../stores/budget-account"; import { useAccountStore } from "../stores/budget-account";
import EditAccount from "../dialogs/EditAccount.vue";
const props = defineProps<{ const props = defineProps<{
budgetid: string budgetid: string
accountid: 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 accountStore = useAccountStore();
const CurrentAccount = accountStore.CurrentAccount; const CurrentAccount = computed(() => accountStore.CurrentAccount);
const TransactionsList = accountStore.TransactionsList; const TransactionsList = computed(() => accountStore.TransactionsList);
</script> </script>
<template> <template>
<h1>{{ CurrentAccount?.Name }}</h1> <h1 class="inline">{{ CurrentAccount?.Name }}</h1>
<EditAccount />
<p> <p>
Current Balance: Current Balance:
<Currency :value="CurrentAccount?.Balance" /> <Currency :value="CurrentAccount?.Balance" />
@ -51,33 +33,9 @@ const TransactionsList = accountStore.TransactionsList;
<td>Memo</td> <td>Memo</td>
<td class="text-right">Amount</td> <td class="text-right">Amount</td>
<td style="width: 20px;"></td> <td style="width: 20px;"></td>
<td style="width: 20px;"></td> <td style="width: 40px;"></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> </tr>
<TransactionInputRow :budgetid="budgetid" :accountid="accountid" />
<TransactionRow <TransactionRow
v-for="(transaction, index) in TransactionsList" v-for="(transaction, index) in TransactionsList"
:transaction="transaction" :transaction="transaction"

View File

@ -1,8 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useSessionStore } from '../stores/session';
onMounted(() => { onMounted(() => {
document.title = "Budgeteer - Admin"; useSessionStore().setTitle("Admin");
}) })
</script> </script>

View File

@ -5,11 +5,6 @@ import { useBudgetsStore } from "../stores/budget"
import { useAccountStore } from "../stores/budget-account" import { useAccountStore } from "../stores/budget-account"
import { useSettingsStore } from "../stores/settings" import { useSettingsStore } from "../stores/settings"
const props = defineProps<{
budgetid: string,
accountid: string,
}>();
const ExpandMenu = computed(() => useSettingsStore().Menu.Expand); const ExpandMenu = computed(() => useSettingsStore().Menu.Expand);
const budgetStore = useBudgetsStore(); const budgetStore = useBudgetsStore();
@ -30,7 +25,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
{{CurrentBudgetName}} {{CurrentBudgetName}}
</span> </span>
<span class="bg-orange-200 rounded-lg m-1 p-1 px-3 flex flex-col"> <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+'/budgeting'">Budget</router-link><br />
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>--> <!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>--> <!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
</span> </span>
@ -40,7 +35,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" /> <Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" />
</div> </div>
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between"> <div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
<router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link> <router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{account.Name}}</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" /> <Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
</div> </div>
</li> </li>
@ -50,7 +45,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" /> <Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" />
</div> </div>
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between"> <div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between">
<router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link> <router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{account.Name}}</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" /> <Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
</div> </div>
</li> </li>

View File

@ -1,13 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineProps, onMounted, PropType, watch, watchEffect } from "vue"; import { computed, defineProps, onMounted, ref, watchEffect } from "vue";
import Currency from "../components/Currency.vue"; import Currency from "../components/Currency.vue";
import { useBudgetsStore } from "../stores/budget"; import { useBudgetsStore } from "../stores/budget";
import { useAccountStore } from "../stores/budget-account"; import { useAccountStore } from "../stores/budget-account";
import { useSessionStore } from "../stores/session";
interface Date {
Year: number,
Month: number,
}
const props = defineProps<{ const props = defineProps<{
budgetid: string, budgetid: string,
@ -18,10 +14,19 @@ const props = defineProps<{
const budgetsStore = useBudgetsStore(); const budgetsStore = useBudgetsStore();
const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID); const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
const categoriesForMonth = useAccountStore().CategoriesForMonth; const accountStore = useAccountStore();
const Categories = computed(() => { const categoriesForMonth = accountStore.CategoriesForMonthAndGroup;
return [...categoriesForMonth(selected.value.Year, selected.value.Month)];
function GetCategories(group : string) {
return [...categoriesForMonth(selected.value.Year, selected.value.Month, group)];
};
const groupsForMonth = accountStore.CategoryGroupsForMonth;
const GroupsForMonth = computed(() => {
return [...groupsForMonth(selected.value.Year, selected.value.Month)];
}); });
const previous = computed(() => ({ const previous = computed(() => ({
Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(), Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(),
Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(), Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(),
@ -44,9 +49,21 @@ watchEffect(() => {
return useAccountStore().FetchMonthBudget(props.budgetid ?? "", Number(props.year), Number(props.month)); return useAccountStore().FetchMonthBudget(props.budgetid ?? "", Number(props.year), Number(props.month));
}); });
/*{{define "title"}} onMounted(() => {
{{printf "Budget for %s %d" .Date.Month .Date.Year}} useSessionStore().setTitle("Budget for " + selected.value.Month + "/" + selected.value.Year);
{{end}}*/ })
const expandedGroups = ref<Map<string, boolean>>(new Map<string, boolean>())
function toggleGroup(group : {Name : string, Expand: boolean}) {
console.log(expandedGroups.value);
expandedGroups.value.set(group.Name, !(expandedGroups.value.get(group.Name) ?? group.Expand))
}
function getGroupState(group : {Name : string, Expand: boolean}) : boolean {
return expandedGroups.value.get(group.Name) ?? group.Expand;
}
</script> </script>
<template> <template>
@ -64,7 +81,6 @@ watchEffect(() => {
</div> </div>
<table class="container col-lg-12" id="content"> <table class="container col-lg-12" id="content">
<tr> <tr>
<th>Group</th>
<th>Category</th> <th>Category</th>
<th></th> <th></th>
<th></th> <th></th>
@ -73,8 +89,9 @@ watchEffect(() => {
<th>Activity</th> <th>Activity</th>
<th>Available</th> <th>Available</th>
</tr> </tr>
<tr v-for="category in Categories"> <tbody v-for="group in GroupsForMonth">
<td>{{ category.Group }}</td> <a class="text-lg font-bold" @click="toggleGroup(group)">{{ (getGroupState(group) ? "" : "+") + " " + group.Name }}</a>
<tr v-for="category in GetCategories(group.Name)" v-if="getGroupState(group)">
<td>{{ category.Name }}</td> <td>{{ category.Name }}</td>
<td></td> <td></td>
<td></td> <td></td>
@ -91,5 +108,6 @@ watchEffect(() => {
<Currency :value="category.Available" /> <Currency :value="category.Available" />
</td> </td>
</tr> </tr>
</tbody>
</table> </table>
</template> </template>

View File

@ -5,17 +5,19 @@ import { useSessionStore } from "../stores/session";
const error = ref(""); const error = ref("");
const login = ref({ user: "", password: "" }); const login = ref({ user: "", password: "" });
const router = useRouter(); // has to be called in setup
onMounted(() => { onMounted(() => {
document.title = "Budgeteer - Login"; useSessionStore().setTitle("Login");
}); });
function formSubmit(e: MouseEvent) { function formSubmit(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
useSessionStore().login(login) useSessionStore().login(login.value)
.then(x => { .then(x => {
error.value = ""; error.value = "";
useRouter().replace("/dashboard"); router.replace("/dashboard");
return x;
}) })
.catch(x => error.value = "The entered credentials are invalid!"); .catch(x => error.value = "The entered credentials are invalid!");
@ -26,18 +28,12 @@ function formSubmit(e: MouseEvent) {
<template> <template>
<div> <div>
<input <input type="text" v-model="login.user"
type="text"
v-model="login.user"
placeholder="Username" placeholder="Username"
class="border-2 border-black rounded-lg block px-2 my-2 w-48" class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
/> <input type="password" v-model="login.password"
<input
type="password"
v-model="login.password"
placeholder="Password" placeholder="Password"
class="border-2 border-black rounded-lg block px-2 my-2 w-48" class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
/>
</div> </div>
<div>{{ error }}</div> <div>{{ error }}</div>
<button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Login</button> <button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Login</button>

View File

@ -1,16 +1,25 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { onMounted, ref } from "vue";
import { useSessionStore } from '../stores/session'; import { useRouter } from "vue-router";
import { useSessionStore } from "../stores/session";
const error = ref(""); const error = ref("");
const login = ref({ email: "", password: "", name: "" }); const login = ref({ email: "", password: "", name: "" });
const showPassword = ref(false); const router = useRouter(); // has to be called in setup
function formSubmit(e: FormDataEvent) { onMounted(() => {
useSessionStore().setTitle("Login");
});
function formSubmit(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
useSessionStore().register(login) useSessionStore().register(login.value)
.then(() => error.value = "") .then(x => {
.catch(() => error.value = "Something went wrong!"); error.value = "";
router.replace("/dashboard");
return x;
})
.catch(x => error.value = "The entered credentials are invalid!");
// TODO display invalidCredentials // TODO display invalidCredentials
// TODO redirect to dashboard on success // TODO redirect to dashboard on success
@ -18,44 +27,21 @@ function formSubmit(e: FormDataEvent) {
</script> </script>
<template> <template>
<v-container> <div>
<v-row> <input type="text" v-model="login.name"
<v-col cols="12"> placeholder="Name"
<v-text-field v-model="login.email" type="text" label="E-Mail" /> class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
</v-col> <input type="text" v-model="login.email"
<v-col cols="12"> placeholder="Email"
<v-text-field v-model="login.name" type="text" label="Name" /> class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
</v-col> <input type="password" v-model="login.password"
<v-col cols="6"> placeholder="Password"
<v-text-field class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
v-model="login.password" </div>
label="Password" <div>{{ error }}</div>
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" <button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Register</button>
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword = showPassword"
:error-message="error"
error-count="2"
error
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="login.password"
label="Repeat password"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword = showPassword"
:error-message="error"
error-count="2"
error
/>
</v-col>
</v-row>
<div class="form-group">{{ error }}</div>
<v-btn type="submit" @click="formSubmit">Register</v-btn>
<p> <p>
Existing user? Existing user?
<router-link to="/login">Login</router-link> instead! <router-link to="/login">Login</router-link> instead!
</p> </p>
</v-container>
</template> </template>

View File

@ -4,15 +4,22 @@ import { useRouter } from "vue-router";
import { DELETE, POST } from "../api"; import { DELETE, POST } from "../api";
import { useBudgetsStore } from "../stores/budget"; import { useBudgetsStore } from "../stores/budget";
import { useSessionStore } from "../stores/session"; import { useSessionStore } from "../stores/session";
import Card from "../components/Card.vue";
import Button from "../components/Button.vue";
import { saveAs } from 'file-saver';
const transactionsFile = ref<File | undefined>(undefined); const transactionsFile = ref<File | undefined>(undefined);
const assignmentsFile = ref<File | undefined>(undefined); const assignmentsFile = ref<File | undefined>(undefined);
const filesIncomplete = computed(() => transactionsFile.value == undefined || assignmentsFile.value == undefined); const filesIncomplete = computed(() => transactionsFile.value == undefined || assignmentsFile.value == undefined);
onMounted(() => { onMounted(() => {
document.title = "Budgeteer - Settings"; useSessionStore().setTitle("Settings");
}); });
const budgetStore = useBudgetsStore();
const CurrentBudgetID = computed(() => budgetStore.CurrentBudgetID);
const CurrentBudgetName = computed(() => budgetStore.CurrentBudgetName);
function gotAssignments(e: Event) { function gotAssignments(e: Event) {
const input = (<HTMLInputElement>e.target); const input = (<HTMLInputElement>e.target);
if (input.files != null) if (input.files != null)
@ -24,19 +31,17 @@ function gotTransactions(e: Event) {
transactionsFile.value = input.files[0]; transactionsFile.value = input.files[0];
}; };
function deleteBudget() { function deleteBudget() {
const currentBudgetID = useBudgetsStore().CurrentBudgetID; if (CurrentBudgetID.value == null)
if (currentBudgetID == null)
return; return;
DELETE("/budget/" + currentBudgetID); DELETE("/budget/" + CurrentBudgetID.value);
const budgetStore = useSessionStore(); const budgetStore = useSessionStore();
budgetStore.Budgets.delete(currentBudgetID); budgetStore.Budgets.delete(CurrentBudgetID.value);
useRouter().push("/") useRouter().push("/")
}; };
function clearBudget() { function clearBudget() {
const currentBudgetID = useBudgetsStore().CurrentBudgetID; POST("/budget/" + CurrentBudgetID.value + "/settings/clear", null)
POST("/budget/" + currentBudgetID + "/settings/clear", null)
}; };
function cleanNegative() { function cleanNegative() {
// <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a> // <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
@ -51,59 +56,49 @@ function ynabImport() {
const budgetStore = useBudgetsStore(); const budgetStore = useBudgetsStore();
budgetStore.ImportYNAB(formData); budgetStore.ImportYNAB(formData);
}; };
function ynabExport() {
const timeStamp = new Date().toISOString();
POST("/budget/"+CurrentBudgetID.value+"/export/ynab/assignments", "")
.then(x => x.text())
.then(x => {
var blob = new Blob([x], {type: "text/plain;charset=utf-8"});
saveAs(blob, timeStamp + " " + CurrentBudgetName.value + " - Budget.tsv");
})
POST("/budget/"+CurrentBudgetID.value+"/export/ynab/transactions", "")
.then(x => x.text())
.then(x => {
var blob = new Blob([x], {type: "text/plain;charset=utf-8"});
saveAs(blob, timeStamp + " " + CurrentBudgetName.value + " - Transactions.tsv");
})
}
</script> </script>
<template> <template>
<v-container> <div>
<h1>Danger Zone</h1> <h1>Danger Zone</h1>
<v-row> <div class="grid md:grid-cols-2 gap-6">
<v-col cols="12" md="6" xl="3"> <Card class="flex-col p-3">
<v-card> <h2 class="text-lg font-bold">Clear Budget</h2>
<v-card-header> <p>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</p>
<v-card-header-text>
<v-card-title>Clear Budget</v-card-title>
<v-card-subtitle>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="clearBudget">Clear budget</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="6" xl="3">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Delete Budget</v-card-title>
<v-card-subtitle>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="deleteBudget">Delete budget</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="6" xl="3">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Fix all historic negative category-balances</v-card-title>
<v-card-subtitle>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="cleanNegative">Fix negative</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" xl="6">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Import YNAB Budget</v-card-title>
</v-card-header-text>
</v-card-header>
<Button class="bg-red-500" @click="clearBudget">Clear budget</Button>
</Card>
<Card class="flex-col p-3">
<h2 class="text-lg font-bold">Delete Budget</h2>
<p>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</p>
<Button class="bg-red-500" @click="deleteBudget">Delete budget</button>
</Card>
<Card class="flex-col p-3">
<h2 class="text-lg font-bold">Fix all historic negative category-balances</h2>
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
<Button class="bg-orange-500" @click="cleanNegative">Fix negative</button>
</Card>
<Card class="flex-col p-3">
<h2 class="text-lg font-bold">Import YNAB Budget</h2>
<div class="flex flex-row">
<div>
<label for="transactions_file"> <label for="transactions_file">
Transaktionen: Transaktionen:
<input type="file" @change="gotTransactions" accept="text/*" /> <input type="file" @change="gotTransactions" accept="text/*" />
@ -113,13 +108,18 @@ function ynabImport() {
Budget: Budget:
<input type="file" @change="gotAssignments" accept="text/*" /> <input type="file" @change="gotAssignments" accept="text/*" />
</label> </label>
</div>
<v-card-actions class="justify-center"> <Button class="bg-blue-500" :disabled="filesIncomplete" @click="ynabImport">Importieren</Button>
<v-btn :disabled="filesIncomplete" @click="ynabImport">Importieren</v-btn> </div>
</v-card-actions> </Card>
</v-card> <Card class="flex-col p-3">
</v-col> <h2 class="text-lg font-bold">Export as YNAB TSV</h2>
</v-row>
<v-card></v-card> <div class="flex flex-row">
</v-container> <Button class="bg-blue-500" @click="ynabExport">Export</Button>
</div>
</Card>
</div>
</div>
</template> </template>

View File

@ -1,5 +1,6 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { GET } from "../api"; import { GET, POST } from "../api";
import { useBudgetsStore } from "./budget";
import { useSessionStore } from "./session"; import { useSessionStore } from "./session";
interface State { interface State {
@ -7,15 +8,31 @@ interface State {
CurrentAccountID: string | null, CurrentAccountID: string | null,
Categories: Map<string, Category>, Categories: Map<string, Category>,
Months: Map<number, Map<number, Map<string, Category>>>, Months: Map<number, Map<number, Map<string, Category>>>,
Transactions: [], Transactions: Map<string, Transaction>,
Assignments: [] Assignments: []
} }
export interface Transaction {
ID: string,
Date: Date,
TransferAccount: string,
CategoryGroup: string,
Category: string,
CategoryID: string | undefined,
Memo: string,
Status: string,
GroupID: string,
Payee: string,
PayeeID: string | undefined,
Amount: number,
}
export interface Account { export interface Account {
ID: string ID: string
Name: string Name: string
OnBudget: boolean OnBudget: boolean
Balance: Number Balance: number
Transactions: string[]
} }
export interface Category { export interface Category {
@ -34,17 +51,39 @@ export const useAccountStore = defineStore("budget/account", {
CurrentAccountID: null, CurrentAccountID: null,
Months: new Map<number, Map<number, Map<string, Category>>>(), Months: new Map<number, Map<number, Map<string, Category>>>(),
Categories: new Map<string, Category>(), Categories: new Map<string, Category>(),
Transactions: [], Transactions: new Map<string, Transaction>(),
Assignments: [] Assignments: []
}), }),
getters: { getters: {
AccountsList(state) { AccountsList(state) {
return [...state.Accounts.values()]; return [...state.Accounts.values()];
}, },
CategoriesForMonth: (state) => (year : number, month : number) => { AllCategoriesForMonth: (state) => (year: number, month: number) => {
console.log("MTH", state.Months)
const yearMap = state.Months.get(year); const yearMap = state.Months.get(year);
return [ ...yearMap?.get(month)?.values() || [] ]; const monthMap = yearMap?.get(month);
return [...monthMap?.values() || []];
},
CategoryGroupsForMonth(state) {
return (year: number, month: number) => {
const categories = this.AllCategoriesForMonth(year, month);
const categoryGroups = [];
let prev = undefined;
for (const category of categories) {
if(category.Group != prev)
categoryGroups.push({
Name: category.Group,
Expand: category.Group != "Hidden Categories",
});
prev = category.Group;
}
return categoryGroups;
}
},
CategoriesForMonthAndGroup(state) {
return (year: number, month: number, group : string) => {
const categories = this.AllCategoriesForMonth(year, month);
return categories.filter(x => x.Group == group);
}
}, },
CurrentAccount(state): Account | undefined { CurrentAccount(state): Account | undefined {
if (state.CurrentAccountID == null) if (state.CurrentAccountID == null)
@ -55,17 +94,19 @@ export const useAccountStore = defineStore("budget/account", {
OnBudgetAccounts(state) { OnBudgetAccounts(state) {
return [...state.Accounts.values()].filter(x => x.OnBudget); return [...state.Accounts.values()].filter(x => x.OnBudget);
}, },
OnBudgetAccountsBalance(state) : Number { OnBudgetAccountsBalance(state): number {
return this.OnBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0); return this.OnBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
}, },
OffBudgetAccounts(state) { OffBudgetAccounts(state) {
return [...state.Accounts.values()].filter(x => !x.OnBudget); return [...state.Accounts.values()].filter(x => !x.OnBudget);
}, },
OffBudgetAccountsBalance(state) : Number { OffBudgetAccountsBalance(state): number {
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0); return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
}, },
TransactionsList(state) { TransactionsList(state) : Transaction[] {
return (state.Transactions || []); return this.CurrentAccount!.Transactions.map(x => {
return this.Transactions.get(x)!
});
} }
}, },
actions: { actions: {
@ -74,36 +115,60 @@ export const useAccountStore = defineStore("budget/account", {
return return
this.CurrentAccountID = accountid; this.CurrentAccountID = accountid;
if (this.CurrentAccount == undefined) const account = this.CurrentAccount;
if (account == undefined)
return return
useSessionStore().setTitle(this.CurrentAccount.Name); useSessionStore().setTitle(account.Name);
await this.FetchAccount(accountid); await this.FetchAccount(account);
}, },
async FetchAccount(accountid : string) { async FetchAccount(account: Account) {
const result = await GET("/account/" + accountid + "/transactions"); const result = await GET("/account/" + account.ID + "/transactions");
const response = await result.json(); const response = await result.json();
this.Transactions = response.Transactions; account.Transactions = [];
for (const transaction of response.Transactions) {
transaction.Date = new Date(transaction.Date);
this.Transactions.set(transaction.ID, transaction);
account.Transactions.push(transaction.ID);
}
}, },
async FetchMonthBudget(budgetid: string, year: number, month: number) { async FetchMonthBudget(budgetid: string, year: number, month: number) {
const result = await GET("/budget/" + budgetid + "/" + year + "/" + month); const result = await GET("/budget/" + budgetid + "/" + year + "/" + month);
const response = await result.json(); const response = await result.json();
if(response.Categories == undefined || response.Categories.length <= 0)
return;
this.addCategoriesForMonth(year, month, response.Categories); this.addCategoriesForMonth(year, month, response.Categories);
}, },
async EditAccount(accountid : string, name : string, onBudget : boolean) {
const result = await POST("/account/" + accountid, JSON.stringify({name: name, onBudget: onBudget}));
const response = await result.json();
useBudgetsStore().MergeBudgetingData(response);
},
addCategoriesForMonth(year: number, month: number, categories: Category[]): void { addCategoriesForMonth(year: number, month: number, categories: Category[]): void {
const yearMap = this.Months.get(year) || new Map<number, Map<string, Category>>(); this.$patch((state) => {
this.Months.set(year, yearMap); const yearMap = state.Months.get(year) || new Map<number, Map<string, Category>>();
const monthMap = yearMap.get(month) || new Map<string, Category>(); const monthMap = yearMap.get(month) || new Map<string, Category>();
yearMap.set(month, monthMap);
for (const category of categories) { for (const category of categories) {
monthMap.set(category.ID, category); monthMap.set(category.ID, category);
} }
yearMap.set(month, monthMap);
state.Months.set(year, yearMap);
});
}, },
logout() { logout() {
this.$reset() this.$reset()
}, },
async saveTransaction(payload: string) {
const result = await POST("/transaction/new", payload);
const response = await result.json();
this.CurrentAccount?.Transactions.unshift(response);
},
async editTransaction(transactionid : string, payload: string) {
const result = await POST("/transaction/" + transactionid, payload);
const response = await result.json();
this.CurrentAccount?.Transactions.unshift(response);
}
} }
}) })

View File

@ -51,6 +51,9 @@ export const useBudgetsStore = defineStore('budget', {
async FetchBudget(budgetid: string) { async FetchBudget(budgetid: string) {
const result = await GET("/budget/" + budgetid); const result = await GET("/budget/" + budgetid);
const response = await result.json(); const response = await result.json();
this.MergeBudgetingData(response);
},
MergeBudgetingData(response : any) {
for (const account of response.Accounts || []) { for (const account of response.Accounts || []) {
useAccountStore().Accounts.set(account.ID, account); useAccountStore().Accounts.set(account.ID, account);
} }

View File

@ -20,12 +20,12 @@ export interface Budget {
export const useSessionStore = defineStore('session', { export const useSessionStore = defineStore('session', {
state: () => ({ state: () => ({
Session: useStorage<Session>('session', null, undefined, { serializer: StorageSerializers.object }), Session: useStorage<Session | null>('session', null, undefined, { serializer: StorageSerializers.object }),
Budgets: useStorage<Map<string, Budget>>('budgets', new Map<string, Budget>(), undefined, { serializer: StorageSerializers.map }), Budgets: useStorage<Map<string, Budget>>('budgets', new Map<string, Budget>(), undefined, { serializer: StorageSerializers.map }),
}), }),
getters: { getters: {
BudgetsList: (state) => [ ...state.Budgets.values() ], BudgetsList: (state) => [ ...state.Budgets.values() ],
AuthHeaders: (state) => ({'Authorization': 'Bearer ' + state.Session.Token}), AuthHeaders: (state) => ({'Authorization': 'Bearer ' + state.Session?.Token}),
LoggedIn: (state) => state.Session != null, LoggedIn: (state) => state.Session != null,
}, },
actions: { actions: {
@ -36,21 +36,26 @@ export const useSessionStore = defineStore('session', {
this.Session = { this.Session = {
User: x.User, User: x.User,
Token: x.Token, Token: x.Token,
}, }
this.Budgets = x.Budgets; for (const budget of x.Budgets) {
this.Budgets.set(budget.ID, budget);
}
}, },
async login(login: any) { async login(login: any) {
const response = await POST("/user/login", JSON.stringify(login)); const response = await POST("/user/login", JSON.stringify(login));
const result = await response.json(); const result = await response.json();
return this.loginSuccess(result); this.loginSuccess(result);
return result;
}, },
async register(login : any) { async register(login : any) {
const response = await POST("/user/register", JSON.stringify(login)); const response = await POST("/user/register", JSON.stringify(login));
const result = await response.json(); const result = await response.json();
return this.loginSuccess(result); this.loginSuccess(result);
return result;
}, },
logout() { logout() {
this.$reset() this.Session = null;
this.Budgets.clear();
}, },
} }
}) })

View File

@ -1141,6 +1141,11 @@
"@types/qs" "*" "@types/qs" "*"
"@types/serve-static" "*" "@types/serve-static" "*"
"@types/file-saver@^2.0.5":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.5.tgz#9ee342a5d1314bb0928375424a2f162f97c310c7"
integrity sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==
"@types/glob@^7.1.1": "@types/glob@^7.1.1":
version "7.2.0" version "7.2.0"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb"
@ -4137,6 +4142,11 @@ figures@^2.0.0:
dependencies: dependencies:
escape-string-regexp "^1.0.5" escape-string-regexp "^1.0.5"
file-saver@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38"
integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==
file-uri-to-path@1.0.0: file-uri-to-path@1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"