Compare commits
	
		
			71 Commits
		
	
	
		
			v0.1.0
			...
			6fe30231d8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe30231d8 | |||
| 49af9cd2ef | |||
| ac27dc783e | |||
| 3c17d674f9 | |||
| 2ec9c923df | |||
| beff7afcf7 | |||
| 951e827d20 | |||
| 2f3e4bc748 | |||
| d71eb17092 | |||
| 53dd31fa35 | |||
| 1a4267186a | |||
| 5018e5b973 | |||
| ed9e75d57a | |||
| ed361324dd | |||
| 6bac09a38e | |||
| ab43387f06 | |||
| c112d95a41 | |||
| 6fdc0e3b1d | |||
| f08784ffa7 | |||
| 8188184ac9 | |||
| 81b3bf334a | |||
| d0ad0dcb3a | |||
| 1ab1fa74e0 | |||
| 33c54c9f4c | |||
| 1ed9344586 | |||
| a8bd03a805 | |||
| 9e01be699a | |||
| 84ddb36d62 | |||
| 8b6a8c3697 | |||
| 208ffce968 | |||
| bfba5f4028 | |||
| 1f2d81f173 | |||
| c3a93377d9 | |||
| 40a299141d | |||
| 935499e3a8 | |||
| 915964fa4e | |||
| e9adc763b2 | |||
| d5ebf5a5cf | |||
| 466775817f | |||
| e2413290b4 | |||
| 18cd29cca2 | |||
| caf0126b86 | |||
| 6da1b26a2f | |||
| 13993b6b5a | |||
| 625e0635fd | |||
| 1826274ccc | |||
| defbbd1884 | |||
| 8116238d48 | |||
| e0eeaadc60 | |||
| 4cd81592e4 | |||
| 5d9693838f | |||
| 3bec0857d5 | |||
| 5e18d51b5d | |||
| 11179a1593 | |||
| 7cb7527704 | |||
| c3a022b595 | |||
| a0ebdd01aa | |||
| edd1319222 | |||
| a19d3d6932 | |||
| f4ddf12214 | |||
| 04fd687324 | |||
| cbda69e827 | |||
| e3f3dc6748 | |||
| 915379f5cb | |||
| 284685fb52 | |||
| 5f4c5d9d51 | |||
| 8c9c78a789 | |||
| 64822912d9 | |||
| 1d4bc158a8 | |||
| fbd283cd1c | |||
| 0ee3f269b5 | 
							
								
								
									
										27
									
								
								.drone.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								.drone.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					---
 | 
				
			||||||
 | 
					kind: pipeline
 | 
				
			||||||
 | 
					type: docker
 | 
				
			||||||
 | 
					name: budgeteer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					steps:
 | 
				
			||||||
 | 
					- name: Taskfile.dev
 | 
				
			||||||
 | 
					  image: hub.javil.eu/budgeteer:dev
 | 
				
			||||||
 | 
					  commands:
 | 
				
			||||||
 | 
					    - task build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- name: docker  
 | 
				
			||||||
 | 
					  image: plugins/docker
 | 
				
			||||||
 | 
					  settings:
 | 
				
			||||||
 | 
					    registry: hub.javil.eu
 | 
				
			||||||
 | 
					    username: 
 | 
				
			||||||
 | 
					      from_secret: docker_user
 | 
				
			||||||
 | 
					    password:
 | 
				
			||||||
 | 
					      from_secret: docker_password
 | 
				
			||||||
 | 
					    repo: hub.javil.eu/budgeteer
 | 
				
			||||||
 | 
					    context: build
 | 
				
			||||||
 | 
					    dockerfile: build/Dockerfile
 | 
				
			||||||
 | 
					    tags: 
 | 
				
			||||||
 | 
					      - latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					image_pull_secrets:
 | 
				
			||||||
 | 
					- hub.javil.eu 
 | 
				
			||||||
							
								
								
									
										11
									
								
								.vscode/tasks.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.vscode/tasks.json
									
									
									
									
										vendored
									
									
								
							@@ -4,14 +4,21 @@
 | 
				
			|||||||
        "version": "2.0.0",
 | 
					        "version": "2.0.0",
 | 
				
			||||||
        "tasks": [
 | 
					        "tasks": [
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                        "label": "earthly +run",
 | 
					                        "label": "task watch +run",
 | 
				
			||||||
                        "type": "shell",
 | 
					                        "type": "shell",
 | 
				
			||||||
                        "command": "earthly +run",
 | 
					                        "command": "task -w run",
 | 
				
			||||||
                        "problemMatcher": [],
 | 
					                        "problemMatcher": [],
 | 
				
			||||||
                        "group": {
 | 
					                        "group": {
 | 
				
			||||||
                                "kind": "build",
 | 
					                                "kind": "build",
 | 
				
			||||||
                                "isDefault": true
 | 
					                                "isDefault": true
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                        "label": "earthly +run",
 | 
				
			||||||
 | 
					                        "type": "shell",
 | 
				
			||||||
 | 
					                        "command": "earthly +run",
 | 
				
			||||||
 | 
					                        "problemMatcher": [],
 | 
				
			||||||
 | 
					                        "group": "build"
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										3
									
								
								Dockerfile.dev
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								Dockerfile.dev
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					FROM golang:1.17
 | 
				
			||||||
 | 
					RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
 | 
				
			||||||
 | 
					RUN go install github.com/go-task/task/v3/cmd/task@latest
 | 
				
			||||||
							
								
								
									
										61
									
								
								Taskfile.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								Taskfile.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
				
			|||||||
 | 
					version: '3'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					tasks:
 | 
				
			||||||
 | 
					  default:
 | 
				
			||||||
 | 
					    cmds:
 | 
				
			||||||
 | 
					      - task: build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  sqlc:
 | 
				
			||||||
 | 
					    desc: sqlc code generation
 | 
				
			||||||
 | 
					    sources:
 | 
				
			||||||
 | 
					      - ./sqlc.yaml
 | 
				
			||||||
 | 
					      - ./postgres/schema/*
 | 
				
			||||||
 | 
					      - ./postgres/queries/*
 | 
				
			||||||
 | 
					    generates:
 | 
				
			||||||
 | 
					      - ./postgres/*.sql.go
 | 
				
			||||||
 | 
					    cmds:
 | 
				
			||||||
 | 
					      - sqlc generate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  gomod:
 | 
				
			||||||
 | 
					    desc: Go modules
 | 
				
			||||||
 | 
					    sources:
 | 
				
			||||||
 | 
					      - ./go.mod
 | 
				
			||||||
 | 
					      - ./go.sum
 | 
				
			||||||
 | 
					    method: checksum
 | 
				
			||||||
 | 
					    cmds:
 | 
				
			||||||
 | 
					      - go mod download
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  build:
 | 
				
			||||||
 | 
					    desc: Build budgeteer
 | 
				
			||||||
 | 
					    deps: [gomod, sqlc]
 | 
				
			||||||
 | 
					    sources:
 | 
				
			||||||
 | 
					      - ./go.mod
 | 
				
			||||||
 | 
					      - ./go.sum
 | 
				
			||||||
 | 
					      - ./cmd/budgeteer/*.go
 | 
				
			||||||
 | 
					      - ./*.go
 | 
				
			||||||
 | 
					      - ./config/*.go
 | 
				
			||||||
 | 
					      - ./http/*.go
 | 
				
			||||||
 | 
					      - ./jwt/*.go
 | 
				
			||||||
 | 
					      - ./postgres/*.go
 | 
				
			||||||
 | 
					      - ./web/**/*
 | 
				
			||||||
 | 
					      - ./postgres/schema/*
 | 
				
			||||||
 | 
					    generates:
 | 
				
			||||||
 | 
					      - build/budgeteer{{exeExt}}
 | 
				
			||||||
 | 
					    env:
 | 
				
			||||||
 | 
					      CGO_ENABLED: '0'
 | 
				
			||||||
 | 
					    cmds:
 | 
				
			||||||
 | 
					      - go build -o ./build/budgeteer{{exeExt}} ./cmd/budgeteer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  docker:
 | 
				
			||||||
 | 
					    desc: Build budgeeter:latest
 | 
				
			||||||
 | 
					    deps: [build]
 | 
				
			||||||
 | 
					    sources:
 | 
				
			||||||
 | 
					      - ./build/budgeteer
 | 
				
			||||||
 | 
					    cmds:
 | 
				
			||||||
 | 
					      - docker build -t budgeteer:latest -t hub.javil.eu/budgeteer:latest ./build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  run:
 | 
				
			||||||
 | 
					    desc: Start docker-compose
 | 
				
			||||||
 | 
					    deps: [docker]
 | 
				
			||||||
 | 
					    cmds:
 | 
				
			||||||
 | 
					      - docker-compose up -d
 | 
				
			||||||
							
								
								
									
										3
									
								
								build/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								build/Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					FROM scratch
 | 
				
			||||||
 | 
					COPY ./budgeteer /app/budgeteer
 | 
				
			||||||
 | 
					ENTRYPOINT ["/app/budgeteer"]
 | 
				
			||||||
@@ -13,25 +13,20 @@ import (
 | 
				
			|||||||
func main() {
 | 
					func main() {
 | 
				
			||||||
	cfg, err := config.LoadConfig()
 | 
						cfg, err := config.LoadConfig()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("Could not load Config: %v", err)
 | 
							log.Fatalf("Could not load config: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	bv := &bcrypt.Verifier{}
 | 
						bv := &bcrypt.Verifier{}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	q, db, err := postgres.Connect(cfg.DatabaseHost, cfg.DatabaseUser, cfg.DatabasePassword, cfg.DatabaseName)
 | 
						q, err := postgres.Connect(cfg.DatabaseHost, cfg.DatabaseUser, cfg.DatabasePassword, cfg.DatabaseName)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("Failed connecting to DB: %v", err)
 | 
							log.Fatalf("Failed connecting to DB: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	us, err := postgres.NewRepository(q, db)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		log.Fatalf("Failed building Repository: %v", err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	tv := &jwt.TokenVerifier{}
 | 
						tv := &jwt.TokenVerifier{}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	h := &http.Handler{
 | 
						h := &http.Handler{
 | 
				
			||||||
		Service:             us,
 | 
							Service:             q,
 | 
				
			||||||
		TokenVerifier:       tv,
 | 
							TokenVerifier:       tv,
 | 
				
			||||||
		CredentialsVerifier: bv,
 | 
							CredentialsVerifier: bv,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										3
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.mod
									
									
									
									
									
								
							@@ -5,7 +5,6 @@ go 1.17
 | 
				
			|||||||
require (
 | 
					require (
 | 
				
			||||||
	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 | 
						github.com/dgrijalva/jwt-go v3.2.0+incompatible
 | 
				
			||||||
	github.com/gin-gonic/gin v1.7.4
 | 
						github.com/gin-gonic/gin v1.7.4
 | 
				
			||||||
	github.com/gofrs/uuid v4.0.0+incompatible
 | 
					 | 
				
			||||||
	github.com/google/uuid v1.3.0
 | 
						github.com/google/uuid v1.3.0
 | 
				
			||||||
	github.com/jackc/pgx/v4 v4.13.0
 | 
						github.com/jackc/pgx/v4 v4.13.0
 | 
				
			||||||
	github.com/pressly/goose/v3 v3.3.1
 | 
						github.com/pressly/goose/v3 v3.3.1
 | 
				
			||||||
@@ -24,7 +23,7 @@ require (
 | 
				
			|||||||
	github.com/jackc/pgpassfile v1.0.0 // indirect
 | 
						github.com/jackc/pgpassfile v1.0.0 // indirect
 | 
				
			||||||
	github.com/jackc/pgproto3/v2 v2.1.1 // indirect
 | 
						github.com/jackc/pgproto3/v2 v2.1.1 // indirect
 | 
				
			||||||
	github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
 | 
						github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
 | 
				
			||||||
	github.com/jackc/pgtype v1.8.1 // indirect
 | 
						github.com/jackc/pgtype v1.8.1 // direct
 | 
				
			||||||
	github.com/json-iterator/go v1.1.9 // indirect
 | 
						github.com/json-iterator/go v1.1.9 // indirect
 | 
				
			||||||
	github.com/leodido/go-urn v1.2.0 // indirect
 | 
						github.com/leodido/go-urn v1.2.0 // indirect
 | 
				
			||||||
	github.com/mattn/go-isatty v0.0.12 // indirect
 | 
						github.com/mattn/go-isatty v0.0.12 // indirect
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										54
									
								
								http/account.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								http/account.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"net/http"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
				
			||||||
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
 | 
						"github.com/google/uuid"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type AccountData struct {
 | 
				
			||||||
 | 
						AlwaysNeededData
 | 
				
			||||||
 | 
						Account      *postgres.Account
 | 
				
			||||||
 | 
						Categories   []postgres.GetCategoriesRow
 | 
				
			||||||
 | 
						Transactions []postgres.GetTransactionsForAccountRow
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (h *Handler) account(c *gin.Context) {
 | 
				
			||||||
 | 
						data := c.MustGet("data").(AlwaysNeededData)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						accountID := c.Param("accountid")
 | 
				
			||||||
 | 
						accountUUID, err := uuid.Parse(accountID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.Redirect(http.StatusTemporaryRedirect, "/login")
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						account, err := h.Service.GetAccount(c.Request.Context(), accountUUID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						categories, err := h.Service.GetCategories(c.Request.Context(), data.Budget.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transactions, err := h.Service.GetTransactionsForAccount(c.Request.Context(), accountUUID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						d := AccountData{
 | 
				
			||||||
 | 
							data,
 | 
				
			||||||
 | 
							&account,
 | 
				
			||||||
 | 
							categories,
 | 
				
			||||||
 | 
							transactions,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.HTML(http.StatusOK, "account.html", d)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,9 +3,7 @@ package http
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
					 | 
				
			||||||
	"github.com/gin-gonic/gin"
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
	"github.com/google/uuid"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type AccountsData struct {
 | 
					type AccountsData struct {
 | 
				
			||||||
@@ -19,39 +17,3 @@ func (h *Handler) accounts(c *gin.Context) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	c.HTML(http.StatusOK, "accounts.html", d)
 | 
						c.HTML(http.StatusOK, "accounts.html", d)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
type AccountData struct {
 | 
					 | 
				
			||||||
	AlwaysNeededData
 | 
					 | 
				
			||||||
	Account      *postgres.Account
 | 
					 | 
				
			||||||
	Transactions []postgres.GetTransactionsForAccountRow
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (h *Handler) account(c *gin.Context) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	accountID := c.Param("accountid")
 | 
					 | 
				
			||||||
	accountUUID, err := uuid.Parse(accountID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.Redirect(http.StatusTemporaryRedirect, "/login")
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	account, err := h.Service.DB.GetAccount(c.Request.Context(), accountUUID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusNotFound, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	transactions, err := h.Service.DB.GetTransactionsForAccount(c.Request.Context(), accountUUID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusNotFound, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	d := AccountData{
 | 
					 | 
				
			||||||
		c.MustGet("data").(AlwaysNeededData),
 | 
					 | 
				
			||||||
		&account,
 | 
					 | 
				
			||||||
		transactions,
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	c.HTML(http.StatusOK, "account.html", d)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,11 @@
 | 
				
			|||||||
package http
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/gin-gonic/gin"
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
 | 
						"github.com/google/uuid"
 | 
				
			||||||
	"github.com/pressly/goose/v3"
 | 
						"github.com/pressly/goose/v3"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,13 +21,97 @@ func (h *Handler) admin(c *gin.Context) {
 | 
				
			|||||||
func (h *Handler) clearDatabase(c *gin.Context) {
 | 
					func (h *Handler) clearDatabase(c *gin.Context) {
 | 
				
			||||||
	d := AdminData{}
 | 
						d := AdminData{}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := goose.Down(h.Service.LegacyDB, "schema"); err != nil {
 | 
						if err := goose.Reset(h.Service.DB, "schema"); err != nil {
 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := goose.Up(h.Service.LegacyDB, "schema"); err != nil {
 | 
						if err := goose.Up(h.Service.DB, "schema"); err != nil {
 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	c.HTML(http.StatusOK, "admin.html", d)
 | 
						c.HTML(http.StatusOK, "admin.html", d)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SettingsData struct {
 | 
				
			||||||
 | 
						AlwaysNeededData
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (h *Handler) settings(c *gin.Context) {
 | 
				
			||||||
 | 
						d := SettingsData{
 | 
				
			||||||
 | 
							c.MustGet("data").(AlwaysNeededData),
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.HTML(http.StatusOK, "settings.html", d)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (h *Handler) clearBudget(c *gin.Context) {
 | 
				
			||||||
 | 
						budgetID := c.Param("budgetid")
 | 
				
			||||||
 | 
						budgetUUID, err := uuid.Parse(budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.Redirect(http.StatusTemporaryRedirect, "/login")
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						rows, err := h.Service.DeleteAllAssignments(c.Request.Context(), budgetUUID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						fmt.Printf("Deleted %d assignments\n", rows)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						rows, err = h.Service.DeleteAllTransactions(c.Request.Context(), budgetUUID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						fmt.Printf("Deleted %d transactions\n", rows)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (h *Handler) cleanNegativeBudget(c *gin.Context) {
 | 
				
			||||||
 | 
						/*budgetID := c.Param("budgetid")
 | 
				
			||||||
 | 
						budgetUUID, err := uuid.Parse(budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.Redirect(http.StatusTemporaryRedirect, "/login")
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/*min_date, err := h.Service.GetFirstActivity(c.Request.Context(), budgetUUID)
 | 
				
			||||||
 | 
						date := getFirstOfMonthTime(min_date)
 | 
				
			||||||
 | 
						for {
 | 
				
			||||||
 | 
							nextDate := date.AddDate(0, 1, 0)
 | 
				
			||||||
 | 
							params := postgres.GetCategoriesWithBalanceParams{
 | 
				
			||||||
 | 
								BudgetID: budgetUUID,
 | 
				
			||||||
 | 
								ToDate:   nextDate,
 | 
				
			||||||
 | 
								FromDate: date,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							categories, err := h.Service.GetCategoriesWithBalance(c.Request.Context(), params)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for _, category := range categories {
 | 
				
			||||||
 | 
								available := category.Available.GetFloat64()
 | 
				
			||||||
 | 
								if available >= 0 {
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								var negativeAvailable postgres.Numeric
 | 
				
			||||||
 | 
								negativeAvailable.Set(-available)
 | 
				
			||||||
 | 
								createAssignment := postgres.CreateAssignmentParams{
 | 
				
			||||||
 | 
									Date:       nextDate.AddDate(0, 0, -1),
 | 
				
			||||||
 | 
									Amount:     negativeAvailable,
 | 
				
			||||||
 | 
									CategoryID: category.ID,
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								h.Service.CreateAssignment(c.Request.Context(), createAssignment)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if nextDate.Before(time.Now()) {
 | 
				
			||||||
 | 
								date = nextDate
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								break
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,6 @@
 | 
				
			|||||||
package http
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
					 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
						"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
				
			||||||
@@ -12,6 +11,8 @@ import (
 | 
				
			|||||||
type AlwaysNeededData struct {
 | 
					type AlwaysNeededData struct {
 | 
				
			||||||
	Budget            postgres.Budget
 | 
						Budget            postgres.Budget
 | 
				
			||||||
	Accounts          []postgres.GetAccountsWithBalanceRow
 | 
						Accounts          []postgres.GetAccountsWithBalanceRow
 | 
				
			||||||
 | 
						OnBudgetAccounts  []postgres.GetAccountsWithBalanceRow
 | 
				
			||||||
 | 
						OffBudgetAccounts []postgres.GetAccountsWithBalanceRow
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (h *Handler) getImportantData(c *gin.Context) {
 | 
					func (h *Handler) getImportantData(c *gin.Context) {
 | 
				
			||||||
@@ -23,20 +24,31 @@ func (h *Handler) getImportantData(c *gin.Context) {
 | 
				
			|||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	budget, err := h.Service.DB.GetBudget(context.Background(), budgetUUID)
 | 
						budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		c.AbortWithError(http.StatusNotFound, err)
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	accounts, err := h.Service.DB.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
 | 
						accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var onBudgetAccounts, offBudgetAccounts []postgres.GetAccountsWithBalanceRow
 | 
				
			||||||
 | 
						for _, account := range accounts {
 | 
				
			||||||
 | 
							if account.OnBudget {
 | 
				
			||||||
 | 
								onBudgetAccounts = append(onBudgetAccounts, account)
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								offBudgetAccounts = append(offBudgetAccounts, account)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	base := AlwaysNeededData{
 | 
						base := AlwaysNeededData{
 | 
				
			||||||
		Accounts:          accounts,
 | 
							Accounts:          accounts,
 | 
				
			||||||
 | 
							OnBudgetAccounts:  onBudgetAccounts,
 | 
				
			||||||
 | 
							OffBudgetAccounts: offBudgetAccounts,
 | 
				
			||||||
		Budget:            budget,
 | 
							Budget:            budget,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,20 +1,22 @@
 | 
				
			|||||||
package http
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
					 | 
				
			||||||
	"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"
 | 
				
			||||||
	"github.com/google/uuid"
 | 
						"github.com/google/uuid"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type BudgetData struct {
 | 
					type AllAccountsData struct {
 | 
				
			||||||
	AlwaysNeededData
 | 
						AlwaysNeededData
 | 
				
			||||||
 | 
						Account      *postgres.Account
 | 
				
			||||||
 | 
						Categories   []postgres.GetCategoriesRow
 | 
				
			||||||
	Transactions []postgres.GetTransactionsForBudgetRow
 | 
						Transactions []postgres.GetTransactionsForBudgetRow
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (h *Handler) budget(c *gin.Context) {
 | 
					func (h *Handler) allAccounts(c *gin.Context) {
 | 
				
			||||||
	budgetID := c.Param("budgetid")
 | 
						budgetID := c.Param("budgetid")
 | 
				
			||||||
	budgetUUID, err := uuid.Parse(budgetID)
 | 
						budgetUUID, err := uuid.Parse(budgetID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -22,16 +24,41 @@ func (h *Handler) budget(c *gin.Context) {
 | 
				
			|||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	transactions, err := h.Service.DB.GetTransactionsForBudget(context.Background(), budgetUUID)
 | 
						categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transactions, err := h.Service.GetTransactionsForBudget(c.Request.Context(), budgetUUID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	d := BudgetData{
 | 
						d := AllAccountsData{
 | 
				
			||||||
		c.MustGet("data").(AlwaysNeededData),
 | 
							c.MustGet("data").(AlwaysNeededData),
 | 
				
			||||||
 | 
							&postgres.Account{
 | 
				
			||||||
 | 
								Name: "All accounts",
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							categories,
 | 
				
			||||||
		transactions,
 | 
							transactions,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	c.HTML(http.StatusOK, "budget.html", d)
 | 
						c.HTML(http.StatusOK, "account.html", d)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (h *Handler) newBudget(c *gin.Context) {
 | 
				
			||||||
 | 
						budgetName, succ := c.GetPostForm("name")
 | 
				
			||||||
 | 
						if !succ {
 | 
				
			||||||
 | 
							c.AbortWithStatus(http.StatusNotAcceptable)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						userID := c.MustGet("token").(budgeteer.Token).GetID()
 | 
				
			||||||
 | 
						_, err := h.Service.NewBudget(c.Request.Context(), budgetName, userID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,6 @@
 | 
				
			|||||||
package http
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
					 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
@@ -9,95 +8,175 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	"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"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type BudgetingData struct {
 | 
					type BudgetingData struct {
 | 
				
			||||||
	AlwaysNeededData
 | 
						AlwaysNeededData
 | 
				
			||||||
	Categories []postgres.GetCategoriesWithBalanceRow
 | 
						Categories       []CategoryWithBalance
 | 
				
			||||||
 | 
						AvailableBalance float64
 | 
				
			||||||
	Date             time.Time
 | 
						Date             time.Time
 | 
				
			||||||
	Next             time.Time
 | 
						Next             time.Time
 | 
				
			||||||
	Previous         time.Time
 | 
						Previous         time.Time
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (h *Handler) budgeting(c *gin.Context) {
 | 
					func getFirstOfMonth(year, month int, location *time.Location) time.Time {
 | 
				
			||||||
	budgetID := c.Param("budgetid")
 | 
						return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
 | 
				
			||||||
	budgetUUID, err := uuid.Parse(budgetID)
 | 
					}
 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.Redirect(http.StatusTemporaryRedirect, "/login")
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	now := time.Now()
 | 
					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          float64
 | 
				
			||||||
 | 
						AvailableLastMonth float64
 | 
				
			||||||
 | 
						Activity           float64
 | 
				
			||||||
 | 
						Assigned           float64
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func getDate(c *gin.Context) (time.Time, error) {
 | 
				
			||||||
	var year, month int
 | 
						var year, month int
 | 
				
			||||||
	yearString := c.Param("year")
 | 
						yearString := c.Param("year")
 | 
				
			||||||
	monthString := c.Param("month")
 | 
						monthString := c.Param("month")
 | 
				
			||||||
	if yearString != "" && monthString != "" {
 | 
						if yearString == "" && monthString == "" {
 | 
				
			||||||
		year, err = strconv.Atoi(yearString)
 | 
							return getFirstOfMonthTime(time.Now()), nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						year, err := strconv.Atoi(yearString)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
			c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
 | 
							return time.Time{}, fmt.Errorf("parse year: %w", err)
 | 
				
			||||||
			return
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	month, err = strconv.Atoi(monthString)
 | 
						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) budgeting(c *gin.Context) {
 | 
				
			||||||
 | 
						alwaysNeededData := c.MustGet("data").(AlwaysNeededData)
 | 
				
			||||||
 | 
						budgetUUID := alwaysNeededData.Budget.ID
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						firstOfMonth, err := getDate(c)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
 | 
							c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	} else {
 | 
					 | 
				
			||||||
		var monthM time.Month
 | 
					 | 
				
			||||||
		year, monthM, _ = now.Date()
 | 
					 | 
				
			||||||
		month = int(monthM)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location())
 | 
					 | 
				
			||||||
	firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
 | 
						firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
 | 
				
			||||||
	firstOfPreviousMonth := firstOfMonth.AddDate(0, -1, 0)
 | 
						firstOfPreviousMonth := firstOfMonth.AddDate(0, -1, 0)
 | 
				
			||||||
 | 
						d := BudgetingData{
 | 
				
			||||||
	params := postgres.GetCategoriesWithBalanceParams{
 | 
							AlwaysNeededData: alwaysNeededData,
 | 
				
			||||||
		BudgetID: budgetUUID,
 | 
							Date:             firstOfMonth,
 | 
				
			||||||
		FromDate: firstOfMonth,
 | 
							Next:             firstOfNextMonth,
 | 
				
			||||||
		ToDate:   firstOfNextMonth,
 | 
							Previous:         firstOfPreviousMonth,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	categories, err := h.Service.DB.GetCategoriesWithBalance(context.Background(), params)
 | 
					
 | 
				
			||||||
 | 
						categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
							c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("load balances: %w", err))
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	d := BudgetingData{
 | 
						// skip everything in the future
 | 
				
			||||||
		c.MustGet("data").(AlwaysNeededData),
 | 
						categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, alwaysNeededData.Budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
 | 
				
			||||||
		categories,
 | 
						if err != nil {
 | 
				
			||||||
		firstOfMonth,
 | 
							return
 | 
				
			||||||
		firstOfNextMonth,
 | 
					 | 
				
			||||||
		firstOfPreviousMonth,
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						d.Categories = categoriesWithBalance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						data := c.MustGet("data").(AlwaysNeededData)
 | 
				
			||||||
 | 
						var availableBalance float64 = 0
 | 
				
			||||||
 | 
						for _, cat := range categories {
 | 
				
			||||||
 | 
							if cat.ID != data.Budget.IncomeCategoryID {
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							availableBalance = moneyUsed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for _, bal := range cumultativeBalances {
 | 
				
			||||||
 | 
								if bal.CategoryID != cat.ID {
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if !bal.Date.Before(firstOfNextMonth) {
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								availableBalance += bal.Transactions.GetFloat64()
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						d.AvailableBalance = availableBalance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	c.HTML(http.StatusOK, "budgeting.html", d)
 | 
						c.HTML(http.StatusOK, "budgeting.html", d)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (h *Handler) clearBudget(c *gin.Context) {
 | 
					func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, float64, error) {
 | 
				
			||||||
	budgetID := c.Param("budgetid")
 | 
						categoriesWithBalance := []CategoryWithBalance{}
 | 
				
			||||||
	budgetUUID, err := uuid.Parse(budgetID)
 | 
						hiddenCategory := CategoryWithBalance{
 | 
				
			||||||
	if err != nil {
 | 
							GetCategoriesRow: &postgres.GetCategoriesRow{
 | 
				
			||||||
		c.Redirect(http.StatusTemporaryRedirect, "/login")
 | 
								Name:  "",
 | 
				
			||||||
		return
 | 
								Group: "Hidden Categories",
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	rows, err := h.Service.DB.DeleteAllAssignments(c.Request.Context(), budgetUUID)
 | 
						var moneyUsed float64 = 0
 | 
				
			||||||
	if err != nil {
 | 
						for i := range categories {
 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
							cat := &categories[i]
 | 
				
			||||||
		return
 | 
							categoryWithBalance := CategoryWithBalance{
 | 
				
			||||||
 | 
								GetCategoriesRow: cat,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							for _, bal := range cumultativeBalances {
 | 
				
			||||||
 | 
								if bal.CategoryID != cat.ID {
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	fmt.Printf("Deleted %d assignments\n", rows)
 | 
								if !bal.Date.Before(firstOfNextMonth) {
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
	rows, err = h.Service.DB.DeleteAllTransactions(c.Request.Context(), budgetUUID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	fmt.Printf("Deleted %d transactions\n", rows)
 | 
								moneyUsed -= bal.Assignments.GetFloat64()
 | 
				
			||||||
 | 
								categoryWithBalance.Available += bal.Assignments.GetFloat64()
 | 
				
			||||||
 | 
								categoryWithBalance.Available += bal.Transactions.GetFloat64()
 | 
				
			||||||
 | 
								if categoryWithBalance.Available < 0 && bal.Date.Before(firstOfMonth) {
 | 
				
			||||||
 | 
									moneyUsed += categoryWithBalance.Available
 | 
				
			||||||
 | 
									categoryWithBalance.Available = 0
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if bal.Date.Before(firstOfMonth) {
 | 
				
			||||||
 | 
									categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
 | 
				
			||||||
 | 
								} else if bal.Date.Before(firstOfNextMonth) {
 | 
				
			||||||
 | 
									categoryWithBalance.Activity = bal.Transactions.GetFloat64()
 | 
				
			||||||
 | 
									categoryWithBalance.Assigned = bal.Assignments.GetFloat64()
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// do not show hidden categories
 | 
				
			||||||
 | 
							if cat.Group == "Hidden Categories" {
 | 
				
			||||||
 | 
								hiddenCategory.Available += categoryWithBalance.Available
 | 
				
			||||||
 | 
								hiddenCategory.AvailableLastMonth += categoryWithBalance.AvailableLastMonth
 | 
				
			||||||
 | 
								hiddenCategory.Activity += categoryWithBalance.Activity
 | 
				
			||||||
 | 
								hiddenCategory.Assigned += categoryWithBalance.Assigned
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if cat.ID == budget.IncomeCategoryID {
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						categoriesWithBalance = append(categoriesWithBalance, hiddenCategory)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return categoriesWithBalance, moneyUsed, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,7 +10,7 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
func (h *Handler) dashboard(c *gin.Context) {
 | 
					func (h *Handler) dashboard(c *gin.Context) {
 | 
				
			||||||
	userID := c.MustGet("token").(budgeteer.Token).GetID()
 | 
						userID := c.MustGet("token").(budgeteer.Token).GetID()
 | 
				
			||||||
	budgets, err := h.Service.BudgetsForUser(userID)
 | 
						budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										130
									
								
								http/http.go
									
									
									
									
									
								
							
							
						
						
									
										130
									
								
								http/http.go
									
									
									
									
									
								
							@@ -1,9 +1,9 @@
 | 
				
			|||||||
package http
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"fmt"
 | 
					 | 
				
			||||||
	"io/fs"
 | 
						"io/fs"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"git.javil.eu/jacob1123/budgeteer"
 | 
						"git.javil.eu/jacob1123/budgeteer"
 | 
				
			||||||
@@ -12,12 +12,11 @@ import (
 | 
				
			|||||||
	"git.javil.eu/jacob1123/budgeteer/web"
 | 
						"git.javil.eu/jacob1123/budgeteer/web"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/gin-gonic/gin"
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
	"github.com/google/uuid"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Handler handles incoming requests
 | 
					// Handler handles incoming requests
 | 
				
			||||||
type Handler struct {
 | 
					type Handler struct {
 | 
				
			||||||
	Service             *postgres.Repository
 | 
						Service             *postgres.Database
 | 
				
			||||||
	TokenVerifier       budgeteer.TokenVerifier
 | 
						TokenVerifier       budgeteer.TokenVerifier
 | 
				
			||||||
	CredentialsVerifier *bcrypt.Verifier
 | 
						CredentialsVerifier *bcrypt.Verifier
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -30,6 +29,7 @@ const (
 | 
				
			|||||||
// Serve starts the HTTP Server
 | 
					// Serve starts the HTTP Server
 | 
				
			||||||
func (h *Handler) Serve() {
 | 
					func (h *Handler) Serve() {
 | 
				
			||||||
	router := gin.Default()
 | 
						router := gin.Default()
 | 
				
			||||||
 | 
						router.FuncMap["now"] = time.Now
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	templates, err := NewTemplates(router.FuncMap)
 | 
						templates, err := NewTemplates(router.FuncMap)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -42,6 +42,7 @@ func (h *Handler) Serve() {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		panic("couldn't open static files")
 | 
							panic("couldn't open static files")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						router.Use(enableCachingForStaticFiles())
 | 
				
			||||||
	router.StaticFS("/static", http.FS(static))
 | 
						router.StaticFS("/static", http.FS(static))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) })
 | 
						router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) })
 | 
				
			||||||
@@ -58,11 +59,14 @@ func (h *Handler) Serve() {
 | 
				
			|||||||
	withBudget.Use(h.verifyLoginWithRedirect)
 | 
						withBudget.Use(h.verifyLoginWithRedirect)
 | 
				
			||||||
	withBudget.Use(h.getImportantData)
 | 
						withBudget.Use(h.getImportantData)
 | 
				
			||||||
	withBudget.GET("/budget/:budgetid", h.budgeting)
 | 
						withBudget.GET("/budget/:budgetid", h.budgeting)
 | 
				
			||||||
	withBudget.GET("/budget/:budgetid/clear", h.clearBudget)
 | 
					 | 
				
			||||||
	withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
 | 
						withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
 | 
				
			||||||
	withBudget.GET("/budget/:budgetid/all-accounts", h.budget)
 | 
						withBudget.GET("/budget/:budgetid/all-accounts", h.allAccounts)
 | 
				
			||||||
	withBudget.GET("/budget/:budgetid/accounts", h.accounts)
 | 
						withBudget.GET("/budget/:budgetid/accounts", h.accounts)
 | 
				
			||||||
	withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
 | 
						withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
 | 
				
			||||||
 | 
						withBudget.GET("/budget/:budgetid/settings", h.settings)
 | 
				
			||||||
 | 
						withBudget.GET("/budget/:budgetid/settings/clear", h.clearBudget)
 | 
				
			||||||
 | 
						withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
 | 
				
			||||||
 | 
						withBudget.GET("/budget/:budgetid/transaction/:transactionid", h.transaction)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	api := router.Group("/api/v1")
 | 
						api := router.Group("/api/v1")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -82,122 +86,16 @@ func (h *Handler) Serve() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	transaction := authenticated.Group("/transaction")
 | 
						transaction := authenticated.Group("/transaction")
 | 
				
			||||||
	transaction.POST("/new", h.newTransaction)
 | 
						transaction.POST("/new", h.newTransaction)
 | 
				
			||||||
 | 
						transaction.POST("/:transactionid", h.newTransaction)
 | 
				
			||||||
	transaction.POST("/import/ynab", h.importYNAB)
 | 
						transaction.POST("/import/ynab", h.importYNAB)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	router.Run(":1323")
 | 
						router.Run(":1323")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (h *Handler) importYNAB(c *gin.Context) {
 | 
					func enableCachingForStaticFiles() gin.HandlerFunc {
 | 
				
			||||||
	budgetID, succ := c.GetPostForm("budget_id")
 | 
						return func(c *gin.Context) {
 | 
				
			||||||
	if !succ {
 | 
							if strings.HasPrefix(c.Request.RequestURI, "/static/") {
 | 
				
			||||||
		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
 | 
								c.Header("Cache-Control", "max-age=86400")
 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	budgetUUID, err := uuid.Parse(budgetID)
 | 
					 | 
				
			||||||
	if !succ {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusBadRequest, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	ynab, err := NewYNABImport(h.Service.DB, budgetUUID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	transactionsFile, err := c.FormFile("transactions")
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	transactions, err := transactionsFile.Open()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	err = ynab.ImportTransactions(transactions)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	assignmentsFile, err := c.FormFile("assignments")
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	assignments, err := assignmentsFile.Open()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	err = ynab.ImportAssignments(assignments)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (h *Handler) newTransaction(c *gin.Context) {
 | 
					 | 
				
			||||||
	transactionMemo, succ := c.GetPostForm("memo")
 | 
					 | 
				
			||||||
	if !succ {
 | 
					 | 
				
			||||||
		c.AbortWithStatus(http.StatusNotAcceptable)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	transactionAccount, succ := c.GetPostForm("account_id")
 | 
					 | 
				
			||||||
	if !succ {
 | 
					 | 
				
			||||||
		c.AbortWithStatus(http.StatusNotAcceptable)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	transactionAccountID, err := uuid.Parse(transactionAccount)
 | 
					 | 
				
			||||||
	if !succ {
 | 
					 | 
				
			||||||
		c.AbortWithStatus(http.StatusNotAcceptable)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	transactionDate, succ := c.GetPostForm("date")
 | 
					 | 
				
			||||||
	if !succ {
 | 
					 | 
				
			||||||
		c.AbortWithStatus(http.StatusNotAcceptable)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithStatus(http.StatusNotAcceptable)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	new := postgres.CreateTransactionParams{
 | 
					 | 
				
			||||||
		Memo:      transactionMemo,
 | 
					 | 
				
			||||||
		Date:      transactionDateValue,
 | 
					 | 
				
			||||||
		Amount:    postgres.Numeric{},
 | 
					 | 
				
			||||||
		AccountID: transactionAccountID,
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	_, err = h.Service.DB.CreateTransaction(c.Request.Context(), new)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (h *Handler) newBudget(c *gin.Context) {
 | 
					 | 
				
			||||||
	budgetName, succ := c.GetPostForm("name")
 | 
					 | 
				
			||||||
	if !succ {
 | 
					 | 
				
			||||||
		c.AbortWithStatus(http.StatusNotAcceptable)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	userID := c.MustGet("token").(budgeteer.Token).GetID()
 | 
					 | 
				
			||||||
	_, err := h.Service.NewBudget(budgetName, userID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -68,7 +68,7 @@ func (h *Handler) loginPost(c *gin.Context) {
 | 
				
			|||||||
	username, _ := c.GetPostForm("username")
 | 
						username, _ := c.GetPostForm("username")
 | 
				
			||||||
	password, _ := c.GetPostForm("password")
 | 
						password, _ := c.GetPostForm("password")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user, err := h.Service.DB.GetUserByUsername(context.Background(), username)
 | 
						user, err := h.Service.GetUserByUsername(c.Request.Context(), username)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		c.AbortWithError(http.StatusUnauthorized, err)
 | 
							c.AbortWithError(http.StatusUnauthorized, err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
@@ -84,7 +84,8 @@ func (h *Handler) loginPost(c *gin.Context) {
 | 
				
			|||||||
		c.AbortWithError(http.StatusUnauthorized, err)
 | 
							c.AbortWithError(http.StatusUnauthorized, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	_, _ = h.Service.DB.UpdateLastLogin(context.Background(), user.ID)
 | 
						go h.Service.UpdateLastLogin(context.Background(), user.ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	maxAge := (int)((expiration * time.Hour).Seconds())
 | 
						maxAge := (int)((expiration * time.Hour).Seconds())
 | 
				
			||||||
	c.SetCookie(authCookie, t, maxAge, "", "", false, true)
 | 
						c.SetCookie(authCookie, t, maxAge, "", "", false, true)
 | 
				
			||||||
	c.JSON(http.StatusOK, map[string]string{
 | 
						c.JSON(http.StatusOK, map[string]string{
 | 
				
			||||||
@@ -97,7 +98,7 @@ func (h *Handler) registerPost(c *gin.Context) {
 | 
				
			|||||||
	password, _ := c.GetPostForm("password")
 | 
						password, _ := c.GetPostForm("password")
 | 
				
			||||||
	name, _ := c.GetPostForm("name")
 | 
						name, _ := c.GetPostForm("name")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	_, err := h.Service.DB.GetUserByUsername(context.Background(), email)
 | 
						_, err := h.Service.GetUserByUsername(c.Request.Context(), email)
 | 
				
			||||||
	if err == nil {
 | 
						if err == nil {
 | 
				
			||||||
		c.AbortWithStatus(http.StatusUnauthorized)
 | 
							c.AbortWithStatus(http.StatusUnauthorized)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
@@ -114,7 +115,7 @@ func (h *Handler) registerPost(c *gin.Context) {
 | 
				
			|||||||
		Password: hash,
 | 
							Password: hash,
 | 
				
			||||||
		Email:    email,
 | 
							Email:    email,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	_, err = h.Service.DB.CreateUser(context.Background(), createUser)
 | 
						_, err = h.Service.CreateUser(c.Request.Context(), createUser)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		c.AbortWithError(http.StatusInternalServerError, err)
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@ type Templates struct {
 | 
				
			|||||||
func NewTemplates(funcMap template.FuncMap) (*Templates, error) {
 | 
					func NewTemplates(funcMap template.FuncMap) (*Templates, error) {
 | 
				
			||||||
	templates, err := fs.Glob(web.Templates, "*.tpl")
 | 
						templates, err := fs.Glob(web.Templates, "*.tpl")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, fmt.Errorf("glob: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	result := &Templates{
 | 
						result := &Templates{
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										62
									
								
								http/transaction-edit.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								http/transaction-edit.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
				
			|||||||
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"net/http"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
				
			||||||
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
 | 
						"github.com/google/uuid"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type TransactionData struct {
 | 
				
			||||||
 | 
						AlwaysNeededData
 | 
				
			||||||
 | 
						Transaction *postgres.Transaction
 | 
				
			||||||
 | 
						Account     *postgres.Account
 | 
				
			||||||
 | 
						Categories  []postgres.GetCategoriesRow
 | 
				
			||||||
 | 
						Payees      []postgres.Payee
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (h *Handler) transaction(c *gin.Context) {
 | 
				
			||||||
 | 
						data := c.MustGet("data").(AlwaysNeededData)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transactionID := c.Param("transactionid")
 | 
				
			||||||
 | 
						transactionUUID, err := uuid.Parse(transactionID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.Redirect(http.StatusTemporaryRedirect, "/login")
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						account, err := h.Service.GetAccount(c.Request.Context(), transaction.AccountID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						categories, err := h.Service.GetCategories(c.Request.Context(), data.Budget.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						payees, err := h.Service.GetPayees(c.Request.Context(), data.Budget.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotFound, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						d := TransactionData{
 | 
				
			||||||
 | 
							data,
 | 
				
			||||||
 | 
							&transaction,
 | 
				
			||||||
 | 
							&account,
 | 
				
			||||||
 | 
							categories,
 | 
				
			||||||
 | 
							payees,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.HTML(http.StatusOK, "transaction.html", d)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										98
									
								
								http/transaction.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								http/transaction.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,98 @@
 | 
				
			|||||||
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"net/http"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
				
			||||||
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (h *Handler) newTransaction(c *gin.Context) {
 | 
				
			||||||
 | 
						transactionMemo, _ := c.GetPostForm("memo")
 | 
				
			||||||
 | 
						transactionAccountID, err := getUUID(c, "account_id")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("account_id: %w", err))
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transactionCategoryID, err := getNullUUIDFromForm(c, "category_id")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("category_id: %w", err))
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transactionPayeeID, err := getNullUUIDFromForm(c, "payee_id")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("payee_id: %w", err))
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transactionDate, succ := c.GetPostForm("date")
 | 
				
			||||||
 | 
						if !succ {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("date missing"))
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("date is not a valid date"))
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						transactionAmount, succ := c.GetPostForm("amount")
 | 
				
			||||||
 | 
						if !succ {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("amount missing"))
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						amount := postgres.Numeric{}
 | 
				
			||||||
 | 
						amount.Set(transactionAmount)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						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:       transactionMemo,
 | 
				
			||||||
 | 
								Date:       transactionDateValue,
 | 
				
			||||||
 | 
								Amount:     amount,
 | 
				
			||||||
 | 
								AccountID:  transactionAccountID,
 | 
				
			||||||
 | 
								PayeeID:    transactionPayeeID,
 | 
				
			||||||
 | 
								CategoryID: transactionCategoryID,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							_, 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:       transactionMemo,
 | 
				
			||||||
 | 
							Date:       transactionDateValue,
 | 
				
			||||||
 | 
							Amount:     amount,
 | 
				
			||||||
 | 
							AccountID:  transactionAccountID,
 | 
				
			||||||
 | 
							PayeeID:    transactionPayeeID,
 | 
				
			||||||
 | 
							CategoryID: transactionCategoryID,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						err = h.Service.UpdateTransaction(c.Request.Context(), update)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										56
									
								
								http/util.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								http/util.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,56 @@
 | 
				
			|||||||
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
 | 
						"github.com/google/uuid"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func getUUID(c *gin.Context, name string) (uuid.UUID, error) {
 | 
				
			||||||
 | 
						value, succ := c.GetPostForm(name)
 | 
				
			||||||
 | 
						if !succ {
 | 
				
			||||||
 | 
							return uuid.UUID{}, fmt.Errorf("not set")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						id, err := uuid.Parse(value)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return uuid.UUID{}, fmt.Errorf("not a valid uuid: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return id, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func getNullUUIDFromParam(c *gin.Context, name string) (uuid.NullUUID, error) {
 | 
				
			||||||
 | 
						value := c.Param(name)
 | 
				
			||||||
 | 
						if value == "" {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						id, err := uuid.Parse(value)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return uuid.NullUUID{
 | 
				
			||||||
 | 
							UUID:  id,
 | 
				
			||||||
 | 
							Valid: true,
 | 
				
			||||||
 | 
						}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func getNullUUIDFromForm(c *gin.Context, name string) (uuid.NullUUID, error) {
 | 
				
			||||||
 | 
						value, succ := c.GetPostForm(name)
 | 
				
			||||||
 | 
						if !succ || value == "" {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						id, err := uuid.Parse(value)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return uuid.NullUUID{
 | 
				
			||||||
 | 
							UUID:  id,
 | 
				
			||||||
 | 
							Valid: true,
 | 
				
			||||||
 | 
						}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,299 +1,66 @@
 | 
				
			|||||||
package http
 | 
					package http
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
					 | 
				
			||||||
	"encoding/csv"
 | 
					 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"io"
 | 
						"net/http"
 | 
				
			||||||
	"strings"
 | 
					 | 
				
			||||||
	"time"
 | 
					 | 
				
			||||||
	"unicode/utf8"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
						"git.javil.eu/jacob1123/budgeteer/postgres"
 | 
				
			||||||
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
	"github.com/google/uuid"
 | 
						"github.com/google/uuid"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type YNABImport struct {
 | 
					func (h *Handler) importYNAB(c *gin.Context) {
 | 
				
			||||||
	Context        context.Context
 | 
						budgetID, succ := c.GetPostForm("budget_id")
 | 
				
			||||||
	accounts       []postgres.Account
 | 
						if !succ {
 | 
				
			||||||
	payees         []postgres.Payee
 | 
							c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
 | 
				
			||||||
	categories     []postgres.GetCategoriesRow
 | 
							return
 | 
				
			||||||
	categoryGroups []postgres.CategoryGroup
 | 
						}
 | 
				
			||||||
	queries        *postgres.Queries
 | 
					
 | 
				
			||||||
	budgetID       uuid.UUID
 | 
						budgetUUID, err := uuid.Parse(budgetID)
 | 
				
			||||||
}
 | 
						if !succ {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusBadRequest, err)
 | 
				
			||||||
func NewYNABImport(q *postgres.Queries, budgetID uuid.UUID) (*YNABImport, error) {
 | 
							return
 | 
				
			||||||
	accounts, err := q.GetAccounts(context.Background(), budgetID)
 | 
						}
 | 
				
			||||||
	if err != nil {
 | 
					
 | 
				
			||||||
		return nil, err
 | 
						ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, budgetUUID)
 | 
				
			||||||
	}
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
	payees, err := q.GetPayees(context.Background(), budgetID)
 | 
							return
 | 
				
			||||||
	if err != nil {
 | 
						}
 | 
				
			||||||
		return nil, err
 | 
					
 | 
				
			||||||
	}
 | 
						transactionsFile, err := c.FormFile("transactions")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
	categories, err := q.GetCategories(context.Background(), budgetID)
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
	if err != nil {
 | 
							return
 | 
				
			||||||
		return nil, err
 | 
						}
 | 
				
			||||||
	}
 | 
					
 | 
				
			||||||
 | 
						transactions, err := transactionsFile.Open()
 | 
				
			||||||
	categoryGroups, err := q.GetCategoryGroups(context.Background(), budgetID)
 | 
						if err != nil {
 | 
				
			||||||
	if err != nil {
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
		return nil, err
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return &YNABImport{
 | 
						err = ynab.ImportTransactions(transactions)
 | 
				
			||||||
		Context:        context.Background(),
 | 
						if err != nil {
 | 
				
			||||||
		accounts:       accounts,
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
		payees:         payees,
 | 
							return
 | 
				
			||||||
		categories:     categories,
 | 
						}
 | 
				
			||||||
		categoryGroups: categoryGroups,
 | 
					
 | 
				
			||||||
		queries:        q,
 | 
						assignmentsFile, err := c.FormFile("assignments")
 | 
				
			||||||
		budgetID:       budgetID,
 | 
						if err != nil {
 | 
				
			||||||
	}, nil
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
 | 
						assignments, err := assignmentsFile.Open()
 | 
				
			||||||
	csv := csv.NewReader(r)
 | 
						if err != nil {
 | 
				
			||||||
	csv.Comma = '\t'
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
	csv.LazyQuotes = true
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	csvData, err := csv.ReadAll()
 | 
					
 | 
				
			||||||
	if err != nil {
 | 
						err = ynab.ImportAssignments(assignments)
 | 
				
			||||||
		return fmt.Errorf("could not read from tsv: %w", err)
 | 
						if err != nil {
 | 
				
			||||||
	}
 | 
							c.AbortWithError(http.StatusInternalServerError, err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
	count := 0
 | 
						}
 | 
				
			||||||
	for _, record := range csvData[1:] {
 | 
					 | 
				
			||||||
		//"Month"	"Category Group/Category"	"Category Group"	"Category"	"Budgeted"	"Activity"	"Available"
 | 
					 | 
				
			||||||
		//"Apr 2019"	"Income: Next Month"	"Income"	"Next Month"	0,00€	0,00€	0,00€
 | 
					 | 
				
			||||||
		dateString := record[0]
 | 
					 | 
				
			||||||
		date, err := time.Parse("Jan 2006", dateString)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not parse date %s: %w", dateString, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		categoryGroup, categoryName := record[2], record[3] //also in 1 joined by :
 | 
					 | 
				
			||||||
		category, err := ynab.GetCategory(categoryGroup, categoryName)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		amountString := record[4]
 | 
					 | 
				
			||||||
		amount, err := GetAmount(amountString, "0,00€")
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not parse amount %s: %w", amountString, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if amount.Int.Int64() == 0 {
 | 
					 | 
				
			||||||
			continue
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		assignment := postgres.CreateAssignmentParams{
 | 
					 | 
				
			||||||
			Date:       date,
 | 
					 | 
				
			||||||
			CategoryID: category.UUID,
 | 
					 | 
				
			||||||
			Amount:     amount,
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		_, err = ynab.queries.CreateAssignment(ynab.Context, assignment)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not save assignment %v: %w", assignment, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		count++
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	fmt.Printf("Imported %d assignments\n", count)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
 | 
					 | 
				
			||||||
	csv := csv.NewReader(r)
 | 
					 | 
				
			||||||
	csv.Comma = '\t'
 | 
					 | 
				
			||||||
	csv.LazyQuotes = true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	csvData, err := csv.ReadAll()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return fmt.Errorf("could not read from tsv: %w", err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	count := 0
 | 
					 | 
				
			||||||
	for _, record := range csvData[1:] {
 | 
					 | 
				
			||||||
		accountName := record[0]
 | 
					 | 
				
			||||||
		account, err := ynab.GetAccount(accountName)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not get account %s: %w", accountName, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		//flag := record[1]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		dateString := record[2]
 | 
					 | 
				
			||||||
		date, err := time.Parse("02.01.2006", dateString)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not parse date %s: %w", dateString, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		payeeName := record[3]
 | 
					 | 
				
			||||||
		payeeID, err := ynab.GetPayee(payeeName)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not get payee %s: %w", payeeName, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		categoryGroup, categoryName := record[5], record[6] //also in 4 joined by :
 | 
					 | 
				
			||||||
		category, err := ynab.GetCategory(categoryGroup, categoryName)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		memo := record[7]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		outflow := record[8]
 | 
					 | 
				
			||||||
		inflow := record[9]
 | 
					 | 
				
			||||||
		amount, err := GetAmount(inflow, outflow)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		//cleared := record[10]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		transaction := postgres.CreateTransactionParams{
 | 
					 | 
				
			||||||
			Date:       date,
 | 
					 | 
				
			||||||
			Memo:       memo,
 | 
					 | 
				
			||||||
			AccountID:  account.ID,
 | 
					 | 
				
			||||||
			PayeeID:    payeeID,
 | 
					 | 
				
			||||||
			CategoryID: category,
 | 
					 | 
				
			||||||
			Amount:     amount,
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return fmt.Errorf("could not save transaction %v: %w", transaction, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		count++
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	fmt.Printf("Imported %d transactions\n", count)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func trimLastChar(s string) string {
 | 
					 | 
				
			||||||
	r, size := utf8.DecodeLastRuneInString(s)
 | 
					 | 
				
			||||||
	if r == utf8.RuneError && (size == 0 || size == 1) {
 | 
					 | 
				
			||||||
		size = 0
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return s[:len(s)-size]
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func GetAmount(inflow string, outflow string) (postgres.Numeric, error) {
 | 
					 | 
				
			||||||
	// Remove trailing currency
 | 
					 | 
				
			||||||
	inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
 | 
					 | 
				
			||||||
	outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	num := postgres.Numeric{}
 | 
					 | 
				
			||||||
	err := num.Set(inflow)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// if inflow is zero, use outflow
 | 
					 | 
				
			||||||
	if num.Int.Int64() != 0 {
 | 
					 | 
				
			||||||
		return num, nil
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	err = num.Set("-" + outflow)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return num, nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (ynab *YNABImport) GetAccount(name string) (*postgres.Account, error) {
 | 
					 | 
				
			||||||
	for _, acc := range ynab.accounts {
 | 
					 | 
				
			||||||
		if acc.Name == name {
 | 
					 | 
				
			||||||
			return &acc, nil
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	account, err := ynab.queries.CreateAccount(ynab.Context, postgres.CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	ynab.accounts = append(ynab.accounts, account)
 | 
					 | 
				
			||||||
	return &account, nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
 | 
					 | 
				
			||||||
	if name == "" {
 | 
					 | 
				
			||||||
		return uuid.NullUUID{}, nil
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	for _, pay := range ynab.payees {
 | 
					 | 
				
			||||||
		if pay.Name == name {
 | 
					 | 
				
			||||||
			return uuid.NullUUID{UUID: pay.ID, Valid: true}, nil
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	payee, err := ynab.queries.CreatePayee(ynab.Context, postgres.CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return uuid.NullUUID{}, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	ynab.payees = append(ynab.payees, payee)
 | 
					 | 
				
			||||||
	return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
 | 
					 | 
				
			||||||
	if group == "" || name == "" {
 | 
					 | 
				
			||||||
		return uuid.NullUUID{}, nil
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	for _, category := range ynab.categories {
 | 
					 | 
				
			||||||
		if category.Name == name && category.Group == group {
 | 
					 | 
				
			||||||
			return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	for _, categoryGroup := range ynab.categoryGroups {
 | 
					 | 
				
			||||||
		if categoryGroup.Name == group {
 | 
					 | 
				
			||||||
			createCategory := postgres.CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
 | 
					 | 
				
			||||||
			category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
 | 
					 | 
				
			||||||
			if err != nil {
 | 
					 | 
				
			||||||
				return uuid.NullUUID{}, err
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			getCategory := postgres.GetCategoriesRow{
 | 
					 | 
				
			||||||
				ID:              category.ID,
 | 
					 | 
				
			||||||
				CategoryGroupID: category.CategoryGroupID,
 | 
					 | 
				
			||||||
				Name:            category.Name,
 | 
					 | 
				
			||||||
				Group:           categoryGroup.Name,
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			ynab.categories = append(ynab.categories, getCategory)
 | 
					 | 
				
			||||||
			return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, postgres.CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return uuid.NullUUID{}, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	category, err := ynab.queries.CreateCategory(ynab.Context, postgres.CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return uuid.NullUUID{}, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	getCategory := postgres.GetCategoriesRow{
 | 
					 | 
				
			||||||
		ID:              category.ID,
 | 
					 | 
				
			||||||
		CategoryGroupID: category.CategoryGroupID,
 | 
					 | 
				
			||||||
		Name:            category.Name,
 | 
					 | 
				
			||||||
		Group:           categoryGroup.Name,
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	ynab.categories = append(ynab.categories, getCategory)
 | 
					 | 
				
			||||||
	return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@ const createAccount = `-- name: CreateAccount :one
 | 
				
			|||||||
INSERT INTO accounts
 | 
					INSERT INTO accounts
 | 
				
			||||||
(name, budget_id)
 | 
					(name, budget_id)
 | 
				
			||||||
VALUES ($1, $2)
 | 
					VALUES ($1, $2)
 | 
				
			||||||
RETURNING id, budget_id, name
 | 
					RETURNING id, budget_id, name, on_budget
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type CreateAccountParams struct {
 | 
					type CreateAccountParams struct {
 | 
				
			||||||
@@ -24,24 +24,34 @@ type CreateAccountParams struct {
 | 
				
			|||||||
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
 | 
					func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
 | 
				
			||||||
	row := q.db.QueryRowContext(ctx, createAccount, arg.Name, arg.BudgetID)
 | 
						row := q.db.QueryRowContext(ctx, createAccount, arg.Name, arg.BudgetID)
 | 
				
			||||||
	var i Account
 | 
						var i Account
 | 
				
			||||||
	err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
 | 
						err := row.Scan(
 | 
				
			||||||
 | 
							&i.ID,
 | 
				
			||||||
 | 
							&i.BudgetID,
 | 
				
			||||||
 | 
							&i.Name,
 | 
				
			||||||
 | 
							&i.OnBudget,
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
	return i, err
 | 
						return i, err
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getAccount = `-- name: GetAccount :one
 | 
					const getAccount = `-- name: GetAccount :one
 | 
				
			||||||
SELECT accounts.id, accounts.budget_id, accounts.name FROM accounts
 | 
					SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts
 | 
				
			||||||
WHERE accounts.id = $1
 | 
					WHERE accounts.id = $1
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error) {
 | 
					func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error) {
 | 
				
			||||||
	row := q.db.QueryRowContext(ctx, getAccount, id)
 | 
						row := q.db.QueryRowContext(ctx, getAccount, id)
 | 
				
			||||||
	var i Account
 | 
						var i Account
 | 
				
			||||||
	err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
 | 
						err := row.Scan(
 | 
				
			||||||
 | 
							&i.ID,
 | 
				
			||||||
 | 
							&i.BudgetID,
 | 
				
			||||||
 | 
							&i.Name,
 | 
				
			||||||
 | 
							&i.OnBudget,
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
	return i, err
 | 
						return i, err
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getAccounts = `-- name: GetAccounts :many
 | 
					const getAccounts = `-- name: GetAccounts :many
 | 
				
			||||||
SELECT accounts.id, accounts.budget_id, accounts.name FROM accounts
 | 
					SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts
 | 
				
			||||||
WHERE accounts.budget_id = $1
 | 
					WHERE accounts.budget_id = $1
 | 
				
			||||||
ORDER BY accounts.name
 | 
					ORDER BY accounts.name
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
@@ -55,7 +65,12 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
 | 
				
			|||||||
	var items []Account
 | 
						var items []Account
 | 
				
			||||||
	for rows.Next() {
 | 
						for rows.Next() {
 | 
				
			||||||
		var i Account
 | 
							var i Account
 | 
				
			||||||
		if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil {
 | 
							if err := rows.Scan(
 | 
				
			||||||
 | 
								&i.ID,
 | 
				
			||||||
 | 
								&i.BudgetID,
 | 
				
			||||||
 | 
								&i.Name,
 | 
				
			||||||
 | 
								&i.OnBudget,
 | 
				
			||||||
 | 
							); err != nil {
 | 
				
			||||||
			return nil, err
 | 
								return nil, err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		items = append(items, i)
 | 
							items = append(items, i)
 | 
				
			||||||
@@ -70,7 +85,7 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
 | 
					const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
 | 
				
			||||||
SELECT accounts.id, accounts.name, SUM(transactions.amount)::decimal(12,2) as balance
 | 
					SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
 | 
				
			||||||
FROM accounts
 | 
					FROM accounts
 | 
				
			||||||
LEFT JOIN transactions ON transactions.account_id = accounts.id
 | 
					LEFT JOIN transactions ON transactions.account_id = accounts.id
 | 
				
			||||||
WHERE accounts.budget_id = $1
 | 
					WHERE accounts.budget_id = $1
 | 
				
			||||||
@@ -82,6 +97,7 @@ ORDER BY accounts.name
 | 
				
			|||||||
type GetAccountsWithBalanceRow struct {
 | 
					type GetAccountsWithBalanceRow struct {
 | 
				
			||||||
	ID       uuid.UUID
 | 
						ID       uuid.UUID
 | 
				
			||||||
	Name     string
 | 
						Name     string
 | 
				
			||||||
 | 
						OnBudget bool
 | 
				
			||||||
	Balance  Numeric
 | 
						Balance  Numeric
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -94,7 +110,12 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
 | 
				
			|||||||
	var items []GetAccountsWithBalanceRow
 | 
						var items []GetAccountsWithBalanceRow
 | 
				
			||||||
	for rows.Next() {
 | 
						for rows.Next() {
 | 
				
			||||||
		var i GetAccountsWithBalanceRow
 | 
							var i GetAccountsWithBalanceRow
 | 
				
			||||||
		if err := rows.Scan(&i.ID, &i.Name, &i.Balance); err != nil {
 | 
							if err := rows.Scan(
 | 
				
			||||||
 | 
								&i.ID,
 | 
				
			||||||
 | 
								&i.Name,
 | 
				
			||||||
 | 
								&i.OnBudget,
 | 
				
			||||||
 | 
								&i.Balance,
 | 
				
			||||||
 | 
							); err != nil {
 | 
				
			||||||
			return nil, err
 | 
								return nil, err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		items = append(items, i)
 | 
							items = append(items, i)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -52,3 +52,37 @@ func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID)
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return result.RowsAffected()
 | 
						return result.RowsAffected()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getAssignmentsByMonthAndCategory = `-- name: GetAssignmentsByMonthAndCategory :many
 | 
				
			||||||
 | 
					SELECT date, category_id, budget_id, amount
 | 
				
			||||||
 | 
					FROM assignments_by_month
 | 
				
			||||||
 | 
					WHERE assignments_by_month.budget_id = $1
 | 
				
			||||||
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) GetAssignmentsByMonthAndCategory(ctx context.Context, budgetID uuid.UUID) ([]AssignmentsByMonth, error) {
 | 
				
			||||||
 | 
						rows, err := q.db.QueryContext(ctx, getAssignmentsByMonthAndCategory, budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						defer rows.Close()
 | 
				
			||||||
 | 
						var items []AssignmentsByMonth
 | 
				
			||||||
 | 
						for rows.Next() {
 | 
				
			||||||
 | 
							var i AssignmentsByMonth
 | 
				
			||||||
 | 
							if err := rows.Scan(
 | 
				
			||||||
 | 
								&i.Date,
 | 
				
			||||||
 | 
								&i.CategoryID,
 | 
				
			||||||
 | 
								&i.BudgetID,
 | 
				
			||||||
 | 
								&i.Amount,
 | 
				
			||||||
 | 
							); err != nil {
 | 
				
			||||||
 | 
								return nil, err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							items = append(items, i)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if err := rows.Close(); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if err := rows.Err(); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return items, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,38 +5,54 @@ package postgres
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
						"context"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/google/uuid"
 | 
						"github.com/google/uuid"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const createBudget = `-- name: CreateBudget :one
 | 
					const createBudget = `-- name: CreateBudget :one
 | 
				
			||||||
INSERT INTO budgets
 | 
					INSERT INTO budgets
 | 
				
			||||||
(name, last_modification)
 | 
					(name, income_category_id, last_modification)
 | 
				
			||||||
VALUES ($1, NOW())
 | 
					VALUES ($1, $2, NOW())
 | 
				
			||||||
RETURNING id, name, last_modification
 | 
					RETURNING id, name, last_modification, income_category_id
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (q *Queries) CreateBudget(ctx context.Context, name string) (Budget, error) {
 | 
					type CreateBudgetParams struct {
 | 
				
			||||||
	row := q.db.QueryRowContext(ctx, createBudget, name)
 | 
						Name             string
 | 
				
			||||||
 | 
						IncomeCategoryID uuid.UUID
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) CreateBudget(ctx context.Context, arg CreateBudgetParams) (Budget, error) {
 | 
				
			||||||
 | 
						row := q.db.QueryRowContext(ctx, createBudget, arg.Name, arg.IncomeCategoryID)
 | 
				
			||||||
	var i Budget
 | 
						var i Budget
 | 
				
			||||||
	err := row.Scan(&i.ID, &i.Name, &i.LastModification)
 | 
						err := row.Scan(
 | 
				
			||||||
 | 
							&i.ID,
 | 
				
			||||||
 | 
							&i.Name,
 | 
				
			||||||
 | 
							&i.LastModification,
 | 
				
			||||||
 | 
							&i.IncomeCategoryID,
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
	return i, err
 | 
						return i, err
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getBudget = `-- name: GetBudget :one
 | 
					const getBudget = `-- name: GetBudget :one
 | 
				
			||||||
SELECT id, name, last_modification FROM budgets 
 | 
					SELECT id, name, last_modification, income_category_id FROM budgets 
 | 
				
			||||||
WHERE id = $1
 | 
					WHERE id = $1
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (q *Queries) GetBudget(ctx context.Context, id uuid.UUID) (Budget, error) {
 | 
					func (q *Queries) GetBudget(ctx context.Context, id uuid.UUID) (Budget, error) {
 | 
				
			||||||
	row := q.db.QueryRowContext(ctx, getBudget, id)
 | 
						row := q.db.QueryRowContext(ctx, getBudget, id)
 | 
				
			||||||
	var i Budget
 | 
						var i Budget
 | 
				
			||||||
	err := row.Scan(&i.ID, &i.Name, &i.LastModification)
 | 
						err := row.Scan(
 | 
				
			||||||
 | 
							&i.ID,
 | 
				
			||||||
 | 
							&i.Name,
 | 
				
			||||||
 | 
							&i.LastModification,
 | 
				
			||||||
 | 
							&i.IncomeCategoryID,
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
	return i, err
 | 
						return i, err
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getBudgetsForUser = `-- name: GetBudgetsForUser :many
 | 
					const getBudgetsForUser = `-- name: GetBudgetsForUser :many
 | 
				
			||||||
SELECT budgets.id, budgets.name, budgets.last_modification FROM budgets 
 | 
					SELECT budgets.id, budgets.name, budgets.last_modification, budgets.income_category_id FROM budgets 
 | 
				
			||||||
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
 | 
					LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
 | 
				
			||||||
WHERE user_budgets.user_id = $1
 | 
					WHERE user_budgets.user_id = $1
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
@@ -50,7 +66,12 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
 | 
				
			|||||||
	var items []Budget
 | 
						var items []Budget
 | 
				
			||||||
	for rows.Next() {
 | 
						for rows.Next() {
 | 
				
			||||||
		var i Budget
 | 
							var i Budget
 | 
				
			||||||
		if err := rows.Scan(&i.ID, &i.Name, &i.LastModification); err != nil {
 | 
							if err := rows.Scan(
 | 
				
			||||||
 | 
								&i.ID,
 | 
				
			||||||
 | 
								&i.Name,
 | 
				
			||||||
 | 
								&i.LastModification,
 | 
				
			||||||
 | 
								&i.IncomeCategoryID,
 | 
				
			||||||
 | 
							); err != nil {
 | 
				
			||||||
			return nil, err
 | 
								return nil, err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		items = append(items, i)
 | 
							items = append(items, i)
 | 
				
			||||||
@@ -63,3 +84,42 @@ func (q *Queries) GetBudgetsForUser(ctx context.Context, userID uuid.UUID) ([]Bu
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return items, nil
 | 
						return items, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getFirstActivity = `-- name: GetFirstActivity :one
 | 
				
			||||||
 | 
					SELECT MIN(dates.min_date)::date as min_date
 | 
				
			||||||
 | 
					FROM (
 | 
				
			||||||
 | 
					        SELECT MIN(assignments.date) as min_date
 | 
				
			||||||
 | 
					        FROM assignments
 | 
				
			||||||
 | 
					        INNER JOIN categories ON categories.id = assignments.category_id
 | 
				
			||||||
 | 
					        INNER JOIN category_groups ON category_groups.id = categories.category_group_id
 | 
				
			||||||
 | 
					        WHERE category_groups.budget_id = $1
 | 
				
			||||||
 | 
					        UNION
 | 
				
			||||||
 | 
					        SELECT MIN(transactions.date) as min_date
 | 
				
			||||||
 | 
					        FROM transactions
 | 
				
			||||||
 | 
					        INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
				
			||||||
 | 
					        WHERE accounts.budget_id = $1
 | 
				
			||||||
 | 
					) dates
 | 
				
			||||||
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) GetFirstActivity(ctx context.Context, budgetID uuid.UUID) (time.Time, error) {
 | 
				
			||||||
 | 
						row := q.db.QueryRowContext(ctx, getFirstActivity, budgetID)
 | 
				
			||||||
 | 
						var min_date time.Time
 | 
				
			||||||
 | 
						err := row.Scan(&min_date)
 | 
				
			||||||
 | 
						return min_date, err
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const setInflowCategory = `-- name: SetInflowCategory :exec
 | 
				
			||||||
 | 
					UPDATE budgets
 | 
				
			||||||
 | 
					        SET income_category_id = $1
 | 
				
			||||||
 | 
					        WHERE budgets.id = $2
 | 
				
			||||||
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SetInflowCategoryParams struct {
 | 
				
			||||||
 | 
						IncomeCategoryID uuid.UUID
 | 
				
			||||||
 | 
						ID               uuid.UUID
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) SetInflowCategory(ctx context.Context, arg SetInflowCategoryParams) error {
 | 
				
			||||||
 | 
						_, err := q.db.ExecContext(ctx, setInflowCategory, arg.IncomeCategoryID, arg.ID)
 | 
				
			||||||
 | 
						return err
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,39 +2,55 @@ package postgres
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
						"context"
 | 
				
			||||||
 | 
						"database/sql"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/google/uuid"
 | 
						"github.com/google/uuid"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Budget returns a budget for a given id.
 | 
					// NewBudget creates a budget and adds it to the current user
 | 
				
			||||||
func (s *Repository) Budget(id uuid.UUID) (*Budget, error) {
 | 
					func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
 | 
				
			||||||
	budget, err := s.DB.GetBudget(context.Background(), id)
 | 
						tx, err := s.BeginTx(context, &sql.TxOptions{})
 | 
				
			||||||
 | 
						q := s.WithTx(tx)
 | 
				
			||||||
 | 
						budget, err := q.CreateBudget(context, CreateBudgetParams{
 | 
				
			||||||
 | 
							Name:             name,
 | 
				
			||||||
 | 
							IncomeCategoryID: uuid.New(),
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, fmt.Errorf("create budget: %w", err)
 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return &budget, nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (s *Repository) BudgetsForUser(id uuid.UUID) ([]Budget, error) {
 | 
					 | 
				
			||||||
	budgets, err := s.DB.GetBudgetsForUser(context.Background(), id)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return budgets, nil
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (s *Repository) NewBudget(name string, userID uuid.UUID) (*Budget, error) {
 | 
					 | 
				
			||||||
	budget, err := s.DB.CreateBudget(context.Background(), name)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
 | 
						ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
 | 
				
			||||||
	_, err = s.DB.LinkBudgetToUser(context.Background(), ub)
 | 
						_, err = q.LinkBudgetToUser(context, ub)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, fmt.Errorf("link budget to user: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						group, err := q.CreateCategoryGroup(context, CreateCategoryGroupParams{
 | 
				
			||||||
 | 
							Name:     "Inflow",
 | 
				
			||||||
 | 
							BudgetID: budget.ID,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("create inflow category_group: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						cat, err := q.CreateCategory(context, CreateCategoryParams{
 | 
				
			||||||
 | 
							Name:            "Ready to Assign",
 | 
				
			||||||
 | 
							CategoryGroupID: group.ID,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("create ready to assign category: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err = q.SetInflowCategory(context, SetInflowCategoryParams{
 | 
				
			||||||
 | 
							IncomeCategoryID: cat.ID,
 | 
				
			||||||
 | 
							ID:               budget.ID,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("set inflow category: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						tx.Commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return &budget, nil
 | 
						return &budget, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,7 +5,6 @@ package postgres
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
						"context"
 | 
				
			||||||
	"time"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/google/uuid"
 | 
						"github.com/google/uuid"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -52,6 +51,7 @@ const getCategories = `-- name: GetCategories :many
 | 
				
			|||||||
SELECT categories.id, categories.category_group_id, categories.name, category_groups.name as group FROM categories
 | 
					SELECT categories.id, categories.category_group_id, categories.name, category_groups.name as group FROM categories
 | 
				
			||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
					INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
				
			||||||
WHERE category_groups.budget_id = $1
 | 
					WHERE category_groups.budget_id = $1
 | 
				
			||||||
 | 
					ORDER BY category_groups.name, categories.name
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type GetCategoriesRow struct {
 | 
					type GetCategoriesRow struct {
 | 
				
			||||||
@@ -89,81 +89,6 @@ func (q *Queries) GetCategories(ctx context.Context, budgetID uuid.UUID) ([]GetC
 | 
				
			|||||||
	return items, nil
 | 
						return items, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getCategoriesWithBalance = `-- name: GetCategoriesWithBalance :many
 | 
					 | 
				
			||||||
SELECT categories.id, categories.name, category_groups.name as group, 
 | 
					 | 
				
			||||||
    (COALESCE(
 | 
					 | 
				
			||||||
        (
 | 
					 | 
				
			||||||
            SELECT SUM(a_hist.amount)
 | 
					 | 
				
			||||||
            FROM assignments a_hist
 | 
					 | 
				
			||||||
            WHERE categories.id = a_hist.category_id 
 | 
					 | 
				
			||||||
            AND a_hist.date < $1
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    , 0)+COALESCE(
 | 
					 | 
				
			||||||
        (
 | 
					 | 
				
			||||||
            SELECT SUM(t_hist.amount)
 | 
					 | 
				
			||||||
            FROM transactions t_hist
 | 
					 | 
				
			||||||
            WHERE categories.id = t_hist.category_id 
 | 
					 | 
				
			||||||
            AND t_hist.date < $1
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    , 0))::decimal(12,2) as balance, 
 | 
					 | 
				
			||||||
    COALESCE(
 | 
					 | 
				
			||||||
        (
 | 
					 | 
				
			||||||
            SELECT SUM(t_this.amount)
 | 
					 | 
				
			||||||
            FROM transactions t_this
 | 
					 | 
				
			||||||
            WHERE categories.id = t_this.category_id 
 | 
					 | 
				
			||||||
            AND t_this.date BETWEEN $1 AND $2
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    , 0)::decimal(12,2) as activity
 | 
					 | 
				
			||||||
FROM categories
 | 
					 | 
				
			||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
					 | 
				
			||||||
WHERE category_groups.budget_id = $3
 | 
					 | 
				
			||||||
GROUP BY categories.id, categories.name, category_groups.name
 | 
					 | 
				
			||||||
ORDER BY category_groups.name, categories.name
 | 
					 | 
				
			||||||
`
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type GetCategoriesWithBalanceParams struct {
 | 
					 | 
				
			||||||
	FromDate time.Time
 | 
					 | 
				
			||||||
	ToDate   time.Time
 | 
					 | 
				
			||||||
	BudgetID uuid.UUID
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type GetCategoriesWithBalanceRow struct {
 | 
					 | 
				
			||||||
	ID       uuid.UUID
 | 
					 | 
				
			||||||
	Name     string
 | 
					 | 
				
			||||||
	Group    string
 | 
					 | 
				
			||||||
	Balance  Numeric
 | 
					 | 
				
			||||||
	Activity Numeric
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (q *Queries) GetCategoriesWithBalance(ctx context.Context, arg GetCategoriesWithBalanceParams) ([]GetCategoriesWithBalanceRow, error) {
 | 
					 | 
				
			||||||
	rows, err := q.db.QueryContext(ctx, getCategoriesWithBalance, arg.FromDate, arg.ToDate, arg.BudgetID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	defer rows.Close()
 | 
					 | 
				
			||||||
	var items []GetCategoriesWithBalanceRow
 | 
					 | 
				
			||||||
	for rows.Next() {
 | 
					 | 
				
			||||||
		var i GetCategoriesWithBalanceRow
 | 
					 | 
				
			||||||
		if err := rows.Scan(
 | 
					 | 
				
			||||||
			&i.ID,
 | 
					 | 
				
			||||||
			&i.Name,
 | 
					 | 
				
			||||||
			&i.Group,
 | 
					 | 
				
			||||||
			&i.Balance,
 | 
					 | 
				
			||||||
			&i.Activity,
 | 
					 | 
				
			||||||
		); err != nil {
 | 
					 | 
				
			||||||
			return nil, err
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		items = append(items, i)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if err := rows.Close(); err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if err := rows.Err(); err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return items, nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const getCategoryGroups = `-- name: GetCategoryGroups :many
 | 
					const getCategoryGroups = `-- name: GetCategoryGroups :many
 | 
				
			||||||
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups 
 | 
					SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups 
 | 
				
			||||||
WHERE category_groups.budget_id = $1
 | 
					WHERE category_groups.budget_id = $1
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,18 +12,26 @@ import (
 | 
				
			|||||||
//go:embed schema/*.sql
 | 
					//go:embed schema/*.sql
 | 
				
			||||||
var migrations embed.FS
 | 
					var migrations embed.FS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Database struct {
 | 
				
			||||||
 | 
						*Queries
 | 
				
			||||||
 | 
						*sql.DB
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Connect to a database
 | 
					// Connect to a database
 | 
				
			||||||
func Connect(server string, user string, password string, database string) (*Queries, *sql.DB, error) {
 | 
					func Connect(server string, user string, password string, database string) (*Database, error) {
 | 
				
			||||||
	connString := fmt.Sprintf("postgres://%s:%s@%s/%s", user, password, server, database)
 | 
						connString := fmt.Sprintf("postgres://%s:%s@%s/%s", user, password, server, database)
 | 
				
			||||||
	conn, err := sql.Open("pgx", connString)
 | 
						conn, err := sql.Open("pgx", connString)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, nil, err
 | 
							return nil, fmt.Errorf("open connection: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	goose.SetBaseFS(migrations)
 | 
						goose.SetBaseFS(migrations)
 | 
				
			||||||
	if err = goose.Up(conn, "schema"); err != nil {
 | 
						if err = goose.Up(conn, "schema"); err != nil {
 | 
				
			||||||
		return nil, nil, err
 | 
							return nil, fmt.Errorf("migrate: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return New(conn), conn, nil
 | 
						return &Database{
 | 
				
			||||||
 | 
							New(conn),
 | 
				
			||||||
 | 
							conn,
 | 
				
			||||||
 | 
						}, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										60
									
								
								postgres/cumultative-balances.sql.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								postgres/cumultative-balances.sql.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
				
			|||||||
 | 
					// Code generated by sqlc. DO NOT EDIT.
 | 
				
			||||||
 | 
					// source: cumultative-balances.sql
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					package postgres
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/google/uuid"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getCumultativeBalances = `-- name: GetCumultativeBalances :many
 | 
				
			||||||
 | 
					SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
 | 
				
			||||||
 | 
					       COALESCE(ass.amount, 0)::decimal(12,2) as assignments, SUM(ass.amount) OVER (PARTITION BY ass.category_id ORDER BY ass.date)::decimal(12,2) as assignments_cum,
 | 
				
			||||||
 | 
					       COALESCE(tra.amount, 0)::decimal(12,2) as transactions, SUM(tra.amount) OVER (PARTITION BY tra.category_id ORDER BY tra.date)::decimal(12,2) as transactions_cum
 | 
				
			||||||
 | 
					FROM assignments_by_month as ass
 | 
				
			||||||
 | 
					FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
 | 
				
			||||||
 | 
					WHERE (ass.budget_id IS NULL OR ass.budget_id = $1) AND (tra.budget_id IS NULL OR tra.budget_id = $1)
 | 
				
			||||||
 | 
					ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id)
 | 
				
			||||||
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type GetCumultativeBalancesRow struct {
 | 
				
			||||||
 | 
						Date            time.Time
 | 
				
			||||||
 | 
						CategoryID      uuid.UUID
 | 
				
			||||||
 | 
						Assignments     Numeric
 | 
				
			||||||
 | 
						AssignmentsCum  Numeric
 | 
				
			||||||
 | 
						Transactions    Numeric
 | 
				
			||||||
 | 
						TransactionsCum Numeric
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) {
 | 
				
			||||||
 | 
						rows, err := q.db.QueryContext(ctx, getCumultativeBalances, budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						defer rows.Close()
 | 
				
			||||||
 | 
						var items []GetCumultativeBalancesRow
 | 
				
			||||||
 | 
						for rows.Next() {
 | 
				
			||||||
 | 
							var i GetCumultativeBalancesRow
 | 
				
			||||||
 | 
							if err := rows.Scan(
 | 
				
			||||||
 | 
								&i.Date,
 | 
				
			||||||
 | 
								&i.CategoryID,
 | 
				
			||||||
 | 
								&i.Assignments,
 | 
				
			||||||
 | 
								&i.AssignmentsCum,
 | 
				
			||||||
 | 
								&i.Transactions,
 | 
				
			||||||
 | 
								&i.TransactionsCum,
 | 
				
			||||||
 | 
							); err != nil {
 | 
				
			||||||
 | 
								return nil, err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							items = append(items, i)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if err := rows.Close(); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if err := rows.Err(); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return items, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -13,6 +13,7 @@ type Account struct {
 | 
				
			|||||||
	ID       uuid.UUID
 | 
						ID       uuid.UUID
 | 
				
			||||||
	BudgetID uuid.UUID
 | 
						BudgetID uuid.UUID
 | 
				
			||||||
	Name     string
 | 
						Name     string
 | 
				
			||||||
 | 
						OnBudget bool
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Assignment struct {
 | 
					type Assignment struct {
 | 
				
			||||||
@@ -23,10 +24,18 @@ type Assignment struct {
 | 
				
			|||||||
	Amount     Numeric
 | 
						Amount     Numeric
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type AssignmentsByMonth struct {
 | 
				
			||||||
 | 
						Date       time.Time
 | 
				
			||||||
 | 
						CategoryID uuid.UUID
 | 
				
			||||||
 | 
						BudgetID   uuid.UUID
 | 
				
			||||||
 | 
						Amount     int64
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Budget struct {
 | 
					type Budget struct {
 | 
				
			||||||
	ID               uuid.UUID
 | 
						ID               uuid.UUID
 | 
				
			||||||
	Name             string
 | 
						Name             string
 | 
				
			||||||
	LastModification sql.NullTime
 | 
						LastModification sql.NullTime
 | 
				
			||||||
 | 
						IncomeCategoryID uuid.UUID
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Category struct {
 | 
					type Category struct {
 | 
				
			||||||
@@ -55,6 +64,14 @@ type Transaction struct {
 | 
				
			|||||||
	AccountID  uuid.UUID
 | 
						AccountID  uuid.UUID
 | 
				
			||||||
	CategoryID uuid.NullUUID
 | 
						CategoryID uuid.NullUUID
 | 
				
			||||||
	PayeeID    uuid.NullUUID
 | 
						PayeeID    uuid.NullUUID
 | 
				
			||||||
 | 
						GroupID    uuid.NullUUID
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type TransactionsByMonth struct {
 | 
				
			||||||
 | 
						Date       time.Time
 | 
				
			||||||
 | 
						CategoryID uuid.NullUUID
 | 
				
			||||||
 | 
						BudgetID   uuid.UUID
 | 
				
			||||||
 | 
						Amount     int64
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type User struct {
 | 
					type User struct {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,6 +7,9 @@ type Numeric struct {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (n Numeric) GetFloat64() float64 {
 | 
					func (n Numeric) GetFloat64() float64 {
 | 
				
			||||||
 | 
						if n.Status != pgtype.Present {
 | 
				
			||||||
 | 
							return 0
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	var balance float64
 | 
						var balance float64
 | 
				
			||||||
	err := n.AssignTo(&balance)
 | 
						err := n.AssignTo(&balance)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -15,7 +18,18 @@ func (n Numeric) GetFloat64() float64 {
 | 
				
			|||||||
	return balance
 | 
						return balance
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (n Numeric) GetPositive() bool {
 | 
					func (n Numeric) IsPositive() bool {
 | 
				
			||||||
 | 
						if n.Status != pgtype.Present {
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	float := n.GetFloat64()
 | 
						float := n.GetFloat64()
 | 
				
			||||||
	return float >= 0
 | 
						return float >= 0
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (n Numeric) IsZero() bool {
 | 
				
			||||||
 | 
						if n.Status != pgtype.Present {
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						float := n.GetFloat64()
 | 
				
			||||||
 | 
						return float == 0
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,6 +31,7 @@ func (q *Queries) CreatePayee(ctx context.Context, arg CreatePayeeParams) (Payee
 | 
				
			|||||||
const getPayees = `-- name: GetPayees :many
 | 
					const getPayees = `-- name: GetPayees :many
 | 
				
			||||||
SELECT payees.id, payees.budget_id, payees.name FROM payees 
 | 
					SELECT payees.id, payees.budget_id, payees.name FROM payees 
 | 
				
			||||||
WHERE payees.budget_id = $1
 | 
					WHERE payees.budget_id = $1
 | 
				
			||||||
 | 
					ORDER BY name
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, error) {
 | 
					func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, error) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,7 +14,7 @@ WHERE accounts.budget_id = $1
 | 
				
			|||||||
ORDER BY accounts.name;
 | 
					ORDER BY accounts.name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: GetAccountsWithBalance :many
 | 
					-- name: GetAccountsWithBalance :many
 | 
				
			||||||
SELECT accounts.id, accounts.name, SUM(transactions.amount)::decimal(12,2) as balance
 | 
					SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
 | 
				
			||||||
FROM accounts
 | 
					FROM accounts
 | 
				
			||||||
LEFT JOIN transactions ON transactions.account_id = accounts.id
 | 
					LEFT JOIN transactions ON transactions.account_id = accounts.id
 | 
				
			||||||
WHERE accounts.budget_id = $1
 | 
					WHERE accounts.budget_id = $1
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,3 +11,8 @@ DELETE FROM assignments
 | 
				
			|||||||
USING categories
 | 
					USING categories
 | 
				
			||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
					INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
				
			||||||
WHERE categories.id = assignments.category_id AND category_groups.budget_id = @budget_id;
 | 
					WHERE categories.id = assignments.category_id AND category_groups.budget_id = @budget_id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: GetAssignmentsByMonthAndCategory :many
 | 
				
			||||||
 | 
					SELECT *
 | 
				
			||||||
 | 
					FROM assignments_by_month
 | 
				
			||||||
 | 
					WHERE assignments_by_month.budget_id = @budget_id;
 | 
				
			||||||
@@ -1,9 +1,14 @@
 | 
				
			|||||||
-- name: CreateBudget :one
 | 
					-- name: CreateBudget :one
 | 
				
			||||||
INSERT INTO budgets
 | 
					INSERT INTO budgets
 | 
				
			||||||
(name, last_modification)
 | 
					(name, income_category_id, last_modification)
 | 
				
			||||||
VALUES ($1, NOW())
 | 
					VALUES ($1, $2, NOW())
 | 
				
			||||||
RETURNING *;
 | 
					RETURNING *;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: SetInflowCategory :exec
 | 
				
			||||||
 | 
					UPDATE budgets
 | 
				
			||||||
 | 
					        SET income_category_id = $1
 | 
				
			||||||
 | 
					        WHERE budgets.id = $2;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: GetBudgetsForUser :many
 | 
					-- name: GetBudgetsForUser :many
 | 
				
			||||||
SELECT budgets.* FROM budgets 
 | 
					SELECT budgets.* FROM budgets 
 | 
				
			||||||
LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
 | 
					LEFT JOIN user_budgets ON budgets.id = user_budgets.budget_id
 | 
				
			||||||
@@ -12,3 +17,18 @@ WHERE user_budgets.user_id = $1;
 | 
				
			|||||||
-- name: GetBudget :one
 | 
					-- name: GetBudget :one
 | 
				
			||||||
SELECT * FROM budgets 
 | 
					SELECT * FROM budgets 
 | 
				
			||||||
WHERE id = $1;
 | 
					WHERE id = $1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: GetFirstActivity :one
 | 
				
			||||||
 | 
					SELECT MIN(dates.min_date)::date as min_date
 | 
				
			||||||
 | 
					FROM (
 | 
				
			||||||
 | 
					        SELECT MIN(assignments.date) as min_date
 | 
				
			||||||
 | 
					        FROM assignments
 | 
				
			||||||
 | 
					        INNER JOIN categories ON categories.id = assignments.category_id
 | 
				
			||||||
 | 
					        INNER JOIN category_groups ON category_groups.id = categories.category_group_id
 | 
				
			||||||
 | 
					        WHERE category_groups.budget_id = @budget_id
 | 
				
			||||||
 | 
					        UNION
 | 
				
			||||||
 | 
					        SELECT MIN(transactions.date) as min_date
 | 
				
			||||||
 | 
					        FROM transactions
 | 
				
			||||||
 | 
					        INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
				
			||||||
 | 
					        WHERE accounts.budget_id = @budget_id
 | 
				
			||||||
 | 
					) dates;
 | 
				
			||||||
@@ -17,35 +17,5 @@ RETURNING *;
 | 
				
			|||||||
-- name: GetCategories :many
 | 
					-- name: GetCategories :many
 | 
				
			||||||
SELECT categories.*, category_groups.name as group FROM categories
 | 
					SELECT categories.*, category_groups.name as group FROM categories
 | 
				
			||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
					INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
				
			||||||
WHERE category_groups.budget_id = $1;
 | 
					WHERE category_groups.budget_id = $1
 | 
				
			||||||
 | 
					 | 
				
			||||||
-- name: GetCategoriesWithBalance :many
 | 
					 | 
				
			||||||
SELECT categories.id, categories.name, category_groups.name as group, 
 | 
					 | 
				
			||||||
    (COALESCE(
 | 
					 | 
				
			||||||
        (
 | 
					 | 
				
			||||||
            SELECT SUM(a_hist.amount)
 | 
					 | 
				
			||||||
            FROM assignments a_hist
 | 
					 | 
				
			||||||
            WHERE categories.id = a_hist.category_id 
 | 
					 | 
				
			||||||
            AND a_hist.date < @from_date
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    , 0)+COALESCE(
 | 
					 | 
				
			||||||
        (
 | 
					 | 
				
			||||||
            SELECT SUM(t_hist.amount)
 | 
					 | 
				
			||||||
            FROM transactions t_hist
 | 
					 | 
				
			||||||
            WHERE categories.id = t_hist.category_id 
 | 
					 | 
				
			||||||
            AND t_hist.date < @from_date
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    , 0))::decimal(12,2) as balance, 
 | 
					 | 
				
			||||||
    COALESCE(
 | 
					 | 
				
			||||||
        (
 | 
					 | 
				
			||||||
            SELECT SUM(t_this.amount)
 | 
					 | 
				
			||||||
            FROM transactions t_this
 | 
					 | 
				
			||||||
            WHERE categories.id = t_this.category_id 
 | 
					 | 
				
			||||||
            AND t_this.date BETWEEN @from_date AND @to_date
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    , 0)::decimal(12,2) as activity
 | 
					 | 
				
			||||||
FROM categories
 | 
					 | 
				
			||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
					 | 
				
			||||||
WHERE category_groups.budget_id = @budget_id
 | 
					 | 
				
			||||||
GROUP BY categories.id, categories.name, category_groups.name
 | 
					 | 
				
			||||||
ORDER BY category_groups.name, categories.name;
 | 
					ORDER BY category_groups.name, categories.name;
 | 
				
			||||||
							
								
								
									
										8
									
								
								postgres/queries/cumultative-balances.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								postgres/queries/cumultative-balances.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					-- name: GetCumultativeBalances :many
 | 
				
			||||||
 | 
					SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
 | 
				
			||||||
 | 
					       COALESCE(ass.amount, 0)::decimal(12,2) as assignments, SUM(ass.amount) OVER (PARTITION BY ass.category_id ORDER BY ass.date)::decimal(12,2) as assignments_cum,
 | 
				
			||||||
 | 
					       COALESCE(tra.amount, 0)::decimal(12,2) as transactions, SUM(tra.amount) OVER (PARTITION BY tra.category_id ORDER BY tra.date)::decimal(12,2) as transactions_cum
 | 
				
			||||||
 | 
					FROM assignments_by_month as ass
 | 
				
			||||||
 | 
					FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
 | 
				
			||||||
 | 
					WHERE (ass.budget_id IS NULL OR ass.budget_id = @budget_id) AND (tra.budget_id IS NULL OR tra.budget_id = @budget_id)
 | 
				
			||||||
 | 
					ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id);
 | 
				
			||||||
@@ -6,4 +6,5 @@ RETURNING *;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
-- name: GetPayees :many
 | 
					-- name: GetPayees :many
 | 
				
			||||||
SELECT payees.* FROM payees 
 | 
					SELECT payees.* FROM payees 
 | 
				
			||||||
WHERE payees.budget_id = $1;
 | 
					WHERE payees.budget_id = $1
 | 
				
			||||||
 | 
					ORDER BY name;
 | 
				
			||||||
@@ -1,11 +1,29 @@
 | 
				
			|||||||
 | 
					-- name: GetTransaction :one
 | 
				
			||||||
 | 
					SELECT * FROM transactions
 | 
				
			||||||
 | 
					WHERE id = $1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: CreateTransaction :one
 | 
					-- name: CreateTransaction :one
 | 
				
			||||||
INSERT INTO transactions
 | 
					INSERT INTO transactions
 | 
				
			||||||
(date, memo, amount, account_id, payee_id, category_id)
 | 
					(date, memo, amount, account_id, payee_id, category_id, group_id)
 | 
				
			||||||
VALUES ($1, $2, $3, $4, $5, $6)
 | 
					VALUES ($1, $2, $3, $4, $5, $6, $7)
 | 
				
			||||||
RETURNING *;
 | 
					RETURNING *;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: UpdateTransaction :exec
 | 
				
			||||||
 | 
					UPDATE transactions
 | 
				
			||||||
 | 
					SET date = $1,
 | 
				
			||||||
 | 
					    memo = $2,
 | 
				
			||||||
 | 
					    amount = $3,
 | 
				
			||||||
 | 
					    account_id = $4,
 | 
				
			||||||
 | 
					    payee_id = $5,
 | 
				
			||||||
 | 
					    category_id = $6
 | 
				
			||||||
 | 
					WHERE id = $7;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: DeleteTransaction :exec
 | 
				
			||||||
 | 
					DELETE FROM transactions
 | 
				
			||||||
 | 
					WHERE id = $1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: GetTransactionsForBudget :many
 | 
					-- name: GetTransactionsForBudget :many
 | 
				
			||||||
SELECT  transactions.id, transactions.date, transactions.memo, transactions.amount,
 | 
					SELECT  transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
 | 
				
			||||||
        accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
 | 
					        accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
 | 
				
			||||||
FROM transactions 
 | 
					FROM transactions 
 | 
				
			||||||
INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
					INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
				
			||||||
@@ -17,7 +35,7 @@ ORDER BY transactions.date DESC
 | 
				
			|||||||
LIMIT 200;
 | 
					LIMIT 200;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: GetTransactionsForAccount :many
 | 
					-- name: GetTransactionsForAccount :many
 | 
				
			||||||
SELECT  transactions.id, transactions.date, transactions.memo, transactions.amount,
 | 
					SELECT  transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
 | 
				
			||||||
        accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
 | 
					        accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
 | 
				
			||||||
FROM transactions 
 | 
					FROM transactions 
 | 
				
			||||||
INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
					INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
				
			||||||
@@ -33,3 +51,8 @@ DELETE FROM transactions
 | 
				
			|||||||
USING accounts
 | 
					USING accounts
 | 
				
			||||||
WHERE accounts.budget_id = @budget_id
 | 
					WHERE accounts.budget_id = @budget_id
 | 
				
			||||||
AND accounts.id = transactions.account_id;
 | 
					AND accounts.id = transactions.account_id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: GetTransactionsByMonthAndCategory :many
 | 
				
			||||||
 | 
					SELECT *
 | 
				
			||||||
 | 
					FROM transactions_by_month
 | 
				
			||||||
 | 
					WHERE transactions_by_month.budget_id = @budget_id;
 | 
				
			||||||
@@ -1,17 +0,0 @@
 | 
				
			|||||||
package postgres
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import "database/sql"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Repository represents a PostgreSQL implementation of all ModelServices
 | 
					 | 
				
			||||||
type Repository struct {
 | 
					 | 
				
			||||||
	DB       *Queries
 | 
					 | 
				
			||||||
	LegacyDB *sql.DB
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func NewRepository(queries *Queries, db *sql.DB) (*Repository, error) {
 | 
					 | 
				
			||||||
	repo := &Repository{
 | 
					 | 
				
			||||||
		DB:       queries,
 | 
					 | 
				
			||||||
		LegacyDB: db,
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return repo, nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										9
									
								
								postgres/schema/0002_budgets.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								postgres/schema/0002_budgets.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE TABLE budgets (
 | 
				
			||||||
 | 
					    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
				
			||||||
 | 
					    name text NOT NULL,
 | 
				
			||||||
 | 
					    last_modification timestamp with time zone
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					DROP TABLE budgets;
 | 
				
			||||||
							
								
								
									
										11
									
								
								postgres/schema/0003_users.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								postgres/schema/0003_users.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE TABLE users (
 | 
				
			||||||
 | 
					    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
				
			||||||
 | 
					    email text NOT NULL,
 | 
				
			||||||
 | 
					    name text NOT NULL,
 | 
				
			||||||
 | 
					    password text NOT NULL,
 | 
				
			||||||
 | 
					    last_login timestamp with time zone
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					DROP TABLE users;
 | 
				
			||||||
							
								
								
									
										8
									
								
								postgres/schema/0004_user_budgets.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								postgres/schema/0004_user_budgets.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE TABLE user_budgets (
 | 
				
			||||||
 | 
					    user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					DROP TABLE user_budgets;
 | 
				
			||||||
							
								
								
									
										10
									
								
								postgres/schema/0005_accounts.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								postgres/schema/0005_accounts.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE TABLE accounts (
 | 
				
			||||||
 | 
					        id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
				
			||||||
 | 
					        budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
 | 
				
			||||||
 | 
					        name varchar(50) NOT NULL,
 | 
				
			||||||
 | 
					        on_budget boolean DEFAULT TRUE NOT NULL
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					DROP TABLE accounts;
 | 
				
			||||||
							
								
								
									
										9
									
								
								postgres/schema/0006_payees.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								postgres/schema/0006_payees.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE TABLE payees (
 | 
				
			||||||
 | 
					        id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
				
			||||||
 | 
					        budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
 | 
				
			||||||
 | 
					        name varchar(50) NOT NULL
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					DROP TABLE payees;
 | 
				
			||||||
							
								
								
									
										9
									
								
								postgres/schema/0007_category-groups.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								postgres/schema/0007_category-groups.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE TABLE category_groups (
 | 
				
			||||||
 | 
					    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
				
			||||||
 | 
					    budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    name varchar(50) NOT NULL
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					DROP TABLE category_groups;
 | 
				
			||||||
							
								
								
									
										12
									
								
								postgres/schema/0008_categories.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								postgres/schema/0008_categories.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE TABLE categories (
 | 
				
			||||||
 | 
					    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
				
			||||||
 | 
					    category_group_id uuid NOT NULL REFERENCES category_groups (id) ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    name varchar(50) NOT NULL
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					ALTER TABLE budgets ADD COLUMN
 | 
				
			||||||
 | 
					    income_category_id uuid NOT NULL REFERENCES categories (id) DEFERRABLE INITIALLY DEFERRED;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					ALTER TABLE budgets DROP COLUMN income_category_id;
 | 
				
			||||||
 | 
					DROP TABLE categories;
 | 
				
			||||||
							
								
								
									
										16
									
								
								postgres/schema/0009_transactions.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								postgres/schema/0009_transactions.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE TABLE transactions (
 | 
				
			||||||
 | 
					    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
				
			||||||
 | 
					    date date NOT NULL,
 | 
				
			||||||
 | 
					    memo text NOT NULL,
 | 
				
			||||||
 | 
					    amount decimal(12,2) NOT NULL,
 | 
				
			||||||
 | 
					    account_id uuid NOT NULL REFERENCES accounts (id),
 | 
				
			||||||
 | 
					    category_id uuid REFERENCES categories (id),
 | 
				
			||||||
 | 
					    payee_id uuid REFERENCES payees (id)
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					ALTER TABLE "transactions" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
 | 
				
			||||||
 | 
					ALTER TABLE "transactions" ADD FOREIGN KEY ("payee_id") REFERENCES "payees" ("id");
 | 
				
			||||||
 | 
					ALTER TABLE "transactions" ADD FOREIGN KEY ("category_id") REFERENCES "categories" ("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					DROP TABLE transactions;
 | 
				
			||||||
							
								
								
									
										17
									
								
								postgres/schema/0011_views-for-months.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								postgres/schema/0011_views-for-months.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					CREATE VIEW transactions_by_month AS 
 | 
				
			||||||
 | 
					        SELECT date_trunc('month', transactions.date)::date as date, transactions.category_id, accounts.budget_id, SUM(amount) as amount
 | 
				
			||||||
 | 
					        FROM transactions
 | 
				
			||||||
 | 
					        INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
				
			||||||
 | 
					        GROUP BY date_trunc('month', transactions.date), transactions.category_id, accounts.budget_id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE VIEW assignments_by_month AS 
 | 
				
			||||||
 | 
					        SELECT date_trunc('month', assignments.date)::date as date, assignments.category_id, category_groups.budget_id, SUM(amount) as amount
 | 
				
			||||||
 | 
					        FROM assignments
 | 
				
			||||||
 | 
					        INNER JOIN categories ON categories.id = assignments.category_id
 | 
				
			||||||
 | 
					        INNER JOIN category_groups ON categories.category_group_id = category_groups.id
 | 
				
			||||||
 | 
					        GROUP BY date_trunc('month', assignments.date), assignments.category_id, category_groups.budget_id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					DROP VIEW transactions_by_month;
 | 
				
			||||||
 | 
					DROP VIEW assignments_by_month;
 | 
				
			||||||
							
								
								
									
										5
									
								
								postgres/schema/0012_add-group-id.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								postgres/schema/0012_add-group-id.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					-- +goose Up
 | 
				
			||||||
 | 
					ALTER TABLE transactions ADD COLUMN group_id uuid NULL;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- +goose Down
 | 
				
			||||||
 | 
					ALTER TABLE transactions DROP COLUMN group_id;
 | 
				
			||||||
@@ -1,66 +0,0 @@
 | 
				
			|||||||
-- +goose Up
 | 
					 | 
				
			||||||
CREATE TABLE budgets (
 | 
					 | 
				
			||||||
    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
					 | 
				
			||||||
    name text NOT NULL,
 | 
					 | 
				
			||||||
    last_modification timestamp with time zone
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE users (
 | 
					 | 
				
			||||||
    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
					 | 
				
			||||||
    email text NOT NULL,
 | 
					 | 
				
			||||||
    name text NOT NULL,
 | 
					 | 
				
			||||||
    password text NOT NULL,
 | 
					 | 
				
			||||||
    last_login timestamp with time zone
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE user_budgets (
 | 
					 | 
				
			||||||
    user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
 | 
					 | 
				
			||||||
    budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE accounts (
 | 
					 | 
				
			||||||
        id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
					 | 
				
			||||||
        budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
 | 
					 | 
				
			||||||
        name varchar(50) NOT NULL
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE payees (
 | 
					 | 
				
			||||||
        id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
					 | 
				
			||||||
        budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
 | 
					 | 
				
			||||||
        name varchar(50) NOT NULL
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE category_groups (
 | 
					 | 
				
			||||||
    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
					 | 
				
			||||||
    budget_id uuid NOT NULL REFERENCES budgets (id) ON DELETE CASCADE,
 | 
					 | 
				
			||||||
    name varchar(50) NOT NULL
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE categories (
 | 
					 | 
				
			||||||
    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
					 | 
				
			||||||
    category_group_id uuid NOT NULL REFERENCES category_groups (id) ON DELETE CASCADE,
 | 
					 | 
				
			||||||
    name varchar(50) NOT NULL
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE transactions (
 | 
					 | 
				
			||||||
    id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
 | 
					 | 
				
			||||||
    date date NOT NULL,
 | 
					 | 
				
			||||||
    memo text NOT NULL,
 | 
					 | 
				
			||||||
    amount decimal(12,2) NOT NULL,
 | 
					 | 
				
			||||||
    account_id uuid NOT NULL REFERENCES accounts (id),
 | 
					 | 
				
			||||||
    category_id uuid REFERENCES categories (id),
 | 
					 | 
				
			||||||
    payee_id uuid REFERENCES payees (id)
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
ALTER TABLE "transactions" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
 | 
					 | 
				
			||||||
ALTER TABLE "transactions" ADD FOREIGN KEY ("payee_id") REFERENCES "payees" ("id");
 | 
					 | 
				
			||||||
ALTER TABLE "transactions" ADD FOREIGN KEY ("category_id") REFERENCES "categories" ("id");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- +goose Down
 | 
					 | 
				
			||||||
DROP TABLE transactions;
 | 
					 | 
				
			||||||
DROP TABLE accounts;
 | 
					 | 
				
			||||||
DROP TABLE payees;
 | 
					 | 
				
			||||||
DROP TABLE categories;
 | 
					 | 
				
			||||||
DROP TABLE category_groups;
 | 
					 | 
				
			||||||
DROP TABLE user_budgets;
 | 
					 | 
				
			||||||
DROP TABLE budgets;
 | 
					 | 
				
			||||||
DROP TABLE users;
 | 
					 | 
				
			||||||
@@ -12,9 +12,9 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const createTransaction = `-- name: CreateTransaction :one
 | 
					const createTransaction = `-- name: CreateTransaction :one
 | 
				
			||||||
INSERT INTO transactions
 | 
					INSERT INTO transactions
 | 
				
			||||||
(date, memo, amount, account_id, payee_id, category_id)
 | 
					(date, memo, amount, account_id, payee_id, category_id, group_id)
 | 
				
			||||||
VALUES ($1, $2, $3, $4, $5, $6)
 | 
					VALUES ($1, $2, $3, $4, $5, $6, $7)
 | 
				
			||||||
RETURNING id, date, memo, amount, account_id, category_id, payee_id
 | 
					RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type CreateTransactionParams struct {
 | 
					type CreateTransactionParams struct {
 | 
				
			||||||
@@ -24,6 +24,7 @@ type CreateTransactionParams struct {
 | 
				
			|||||||
	AccountID  uuid.UUID
 | 
						AccountID  uuid.UUID
 | 
				
			||||||
	PayeeID    uuid.NullUUID
 | 
						PayeeID    uuid.NullUUID
 | 
				
			||||||
	CategoryID uuid.NullUUID
 | 
						CategoryID uuid.NullUUID
 | 
				
			||||||
 | 
						GroupID    uuid.NullUUID
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {
 | 
					func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {
 | 
				
			||||||
@@ -34,6 +35,7 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
 | 
				
			|||||||
		arg.AccountID,
 | 
							arg.AccountID,
 | 
				
			||||||
		arg.PayeeID,
 | 
							arg.PayeeID,
 | 
				
			||||||
		arg.CategoryID,
 | 
							arg.CategoryID,
 | 
				
			||||||
 | 
							arg.GroupID,
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	var i Transaction
 | 
						var i Transaction
 | 
				
			||||||
	err := row.Scan(
 | 
						err := row.Scan(
 | 
				
			||||||
@@ -44,6 +46,7 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
 | 
				
			|||||||
		&i.AccountID,
 | 
							&i.AccountID,
 | 
				
			||||||
		&i.CategoryID,
 | 
							&i.CategoryID,
 | 
				
			||||||
		&i.PayeeID,
 | 
							&i.PayeeID,
 | 
				
			||||||
 | 
							&i.GroupID,
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	return i, err
 | 
						return i, err
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -63,8 +66,73 @@ func (q *Queries) DeleteAllTransactions(ctx context.Context, budgetID uuid.UUID)
 | 
				
			|||||||
	return result.RowsAffected()
 | 
						return result.RowsAffected()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const deleteTransaction = `-- name: DeleteTransaction :exec
 | 
				
			||||||
 | 
					DELETE FROM transactions
 | 
				
			||||||
 | 
					WHERE id = $1
 | 
				
			||||||
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
 | 
				
			||||||
 | 
						_, err := q.db.ExecContext(ctx, deleteTransaction, id)
 | 
				
			||||||
 | 
						return err
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getTransaction = `-- name: GetTransaction :one
 | 
				
			||||||
 | 
					SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id FROM transactions
 | 
				
			||||||
 | 
					WHERE id = $1
 | 
				
			||||||
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (Transaction, error) {
 | 
				
			||||||
 | 
						row := q.db.QueryRowContext(ctx, getTransaction, id)
 | 
				
			||||||
 | 
						var i Transaction
 | 
				
			||||||
 | 
						err := row.Scan(
 | 
				
			||||||
 | 
							&i.ID,
 | 
				
			||||||
 | 
							&i.Date,
 | 
				
			||||||
 | 
							&i.Memo,
 | 
				
			||||||
 | 
							&i.Amount,
 | 
				
			||||||
 | 
							&i.AccountID,
 | 
				
			||||||
 | 
							&i.CategoryID,
 | 
				
			||||||
 | 
							&i.PayeeID,
 | 
				
			||||||
 | 
							&i.GroupID,
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						return i, err
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getTransactionsByMonthAndCategory = `-- name: GetTransactionsByMonthAndCategory :many
 | 
				
			||||||
 | 
					SELECT date, category_id, budget_id, amount
 | 
				
			||||||
 | 
					FROM transactions_by_month
 | 
				
			||||||
 | 
					WHERE transactions_by_month.budget_id = $1
 | 
				
			||||||
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetID uuid.UUID) ([]TransactionsByMonth, error) {
 | 
				
			||||||
 | 
						rows, err := q.db.QueryContext(ctx, getTransactionsByMonthAndCategory, budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						defer rows.Close()
 | 
				
			||||||
 | 
						var items []TransactionsByMonth
 | 
				
			||||||
 | 
						for rows.Next() {
 | 
				
			||||||
 | 
							var i TransactionsByMonth
 | 
				
			||||||
 | 
							if err := rows.Scan(
 | 
				
			||||||
 | 
								&i.Date,
 | 
				
			||||||
 | 
								&i.CategoryID,
 | 
				
			||||||
 | 
								&i.BudgetID,
 | 
				
			||||||
 | 
								&i.Amount,
 | 
				
			||||||
 | 
							); err != nil {
 | 
				
			||||||
 | 
								return nil, err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							items = append(items, i)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if err := rows.Close(); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if err := rows.Err(); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return items, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
 | 
					const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
 | 
				
			||||||
SELECT  transactions.id, transactions.date, transactions.memo, transactions.amount,
 | 
					SELECT  transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
 | 
				
			||||||
        accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
 | 
					        accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
 | 
				
			||||||
FROM transactions 
 | 
					FROM transactions 
 | 
				
			||||||
INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
					INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
				
			||||||
@@ -81,6 +149,7 @@ type GetTransactionsForAccountRow struct {
 | 
				
			|||||||
	Date          time.Time
 | 
						Date          time.Time
 | 
				
			||||||
	Memo          string
 | 
						Memo          string
 | 
				
			||||||
	Amount        Numeric
 | 
						Amount        Numeric
 | 
				
			||||||
 | 
						GroupID       uuid.NullUUID
 | 
				
			||||||
	Account       string
 | 
						Account       string
 | 
				
			||||||
	Payee         string
 | 
						Payee         string
 | 
				
			||||||
	CategoryGroup string
 | 
						CategoryGroup string
 | 
				
			||||||
@@ -101,6 +170,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
 | 
				
			|||||||
			&i.Date,
 | 
								&i.Date,
 | 
				
			||||||
			&i.Memo,
 | 
								&i.Memo,
 | 
				
			||||||
			&i.Amount,
 | 
								&i.Amount,
 | 
				
			||||||
 | 
								&i.GroupID,
 | 
				
			||||||
			&i.Account,
 | 
								&i.Account,
 | 
				
			||||||
			&i.Payee,
 | 
								&i.Payee,
 | 
				
			||||||
			&i.CategoryGroup,
 | 
								&i.CategoryGroup,
 | 
				
			||||||
@@ -120,7 +190,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
 | 
					const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
 | 
				
			||||||
SELECT  transactions.id, transactions.date, transactions.memo, transactions.amount,
 | 
					SELECT  transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
 | 
				
			||||||
        accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
 | 
					        accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
 | 
				
			||||||
FROM transactions 
 | 
					FROM transactions 
 | 
				
			||||||
INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
					INNER JOIN accounts ON accounts.id = transactions.account_id
 | 
				
			||||||
@@ -137,6 +207,7 @@ type GetTransactionsForBudgetRow struct {
 | 
				
			|||||||
	Date          time.Time
 | 
						Date          time.Time
 | 
				
			||||||
	Memo          string
 | 
						Memo          string
 | 
				
			||||||
	Amount        Numeric
 | 
						Amount        Numeric
 | 
				
			||||||
 | 
						GroupID       uuid.NullUUID
 | 
				
			||||||
	Account       string
 | 
						Account       string
 | 
				
			||||||
	Payee         string
 | 
						Payee         string
 | 
				
			||||||
	CategoryGroup string
 | 
						CategoryGroup string
 | 
				
			||||||
@@ -157,6 +228,7 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
 | 
				
			|||||||
			&i.Date,
 | 
								&i.Date,
 | 
				
			||||||
			&i.Memo,
 | 
								&i.Memo,
 | 
				
			||||||
			&i.Amount,
 | 
								&i.Amount,
 | 
				
			||||||
 | 
								&i.GroupID,
 | 
				
			||||||
			&i.Account,
 | 
								&i.Account,
 | 
				
			||||||
			&i.Payee,
 | 
								&i.Payee,
 | 
				
			||||||
			&i.CategoryGroup,
 | 
								&i.CategoryGroup,
 | 
				
			||||||
@@ -174,3 +246,37 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return items, nil
 | 
						return items, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const updateTransaction = `-- name: UpdateTransaction :exec
 | 
				
			||||||
 | 
					UPDATE transactions
 | 
				
			||||||
 | 
					SET date = $1,
 | 
				
			||||||
 | 
					    memo = $2,
 | 
				
			||||||
 | 
					    amount = $3,
 | 
				
			||||||
 | 
					    account_id = $4,
 | 
				
			||||||
 | 
					    payee_id = $5,
 | 
				
			||||||
 | 
					    category_id = $6
 | 
				
			||||||
 | 
					WHERE id = $7
 | 
				
			||||||
 | 
					`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type UpdateTransactionParams struct {
 | 
				
			||||||
 | 
						Date       time.Time
 | 
				
			||||||
 | 
						Memo       string
 | 
				
			||||||
 | 
						Amount     Numeric
 | 
				
			||||||
 | 
						AccountID  uuid.UUID
 | 
				
			||||||
 | 
						PayeeID    uuid.NullUUID
 | 
				
			||||||
 | 
						CategoryID uuid.NullUUID
 | 
				
			||||||
 | 
						ID         uuid.UUID
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionParams) error {
 | 
				
			||||||
 | 
						_, err := q.db.ExecContext(ctx, updateTransaction,
 | 
				
			||||||
 | 
							arg.Date,
 | 
				
			||||||
 | 
							arg.Memo,
 | 
				
			||||||
 | 
							arg.Amount,
 | 
				
			||||||
 | 
							arg.AccountID,
 | 
				
			||||||
 | 
							arg.PayeeID,
 | 
				
			||||||
 | 
							arg.CategoryID,
 | 
				
			||||||
 | 
							arg.ID,
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						return err
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										374
									
								
								postgres/ynab-import.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										374
									
								
								postgres/ynab-import.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,374 @@
 | 
				
			|||||||
 | 
					package postgres
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
 | 
						"encoding/csv"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"io"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
						"unicode/utf8"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/google/uuid"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type YNABImport struct {
 | 
				
			||||||
 | 
						Context        context.Context
 | 
				
			||||||
 | 
						accounts       []Account
 | 
				
			||||||
 | 
						payees         []Payee
 | 
				
			||||||
 | 
						categories     []GetCategoriesRow
 | 
				
			||||||
 | 
						categoryGroups []CategoryGroup
 | 
				
			||||||
 | 
						queries        *Queries
 | 
				
			||||||
 | 
						budgetID       uuid.UUID
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func NewYNABImport(context context.Context, q *Queries, budgetID uuid.UUID) (*YNABImport, error) {
 | 
				
			||||||
 | 
						accounts, err := q.GetAccounts(context, budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						payees, err := q.GetPayees(context, budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						categories, err := q.GetCategories(context, budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						categoryGroups, err := q.GetCategoryGroups(context, budgetID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return &YNABImport{
 | 
				
			||||||
 | 
							Context:        context,
 | 
				
			||||||
 | 
							accounts:       accounts,
 | 
				
			||||||
 | 
							payees:         payees,
 | 
				
			||||||
 | 
							categories:     categories,
 | 
				
			||||||
 | 
							categoryGroups: categoryGroups,
 | 
				
			||||||
 | 
							queries:        q,
 | 
				
			||||||
 | 
							budgetID:       budgetID,
 | 
				
			||||||
 | 
						}, nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ImportAssignments expects a TSV-file as exported by YNAB in the following format:
 | 
				
			||||||
 | 
					//"Month"	"Category Group/Category"	"Category Group"	"Category"	"Budgeted"	"Activity"	"Available"
 | 
				
			||||||
 | 
					//"Apr 2019"	"Income: Next Month"	"Income"	"Next Month"	0,00€	0,00€	0,00€
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					// Activity and Available are not imported, since they are determined by the transactions and historic assignments
 | 
				
			||||||
 | 
					func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
 | 
				
			||||||
 | 
						csv := csv.NewReader(r)
 | 
				
			||||||
 | 
						csv.Comma = '\t'
 | 
				
			||||||
 | 
						csv.LazyQuotes = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						csvData, err := csv.ReadAll()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return fmt.Errorf("could not read from tsv: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						count := 0
 | 
				
			||||||
 | 
						for _, record := range csvData[1:] {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							dateString := record[0]
 | 
				
			||||||
 | 
							date, err := time.Parse("Jan 2006", dateString)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not parse date %s: %w", dateString, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							categoryGroup, categoryName := record[2], record[3] //also in 1 joined by :
 | 
				
			||||||
 | 
							category, err := ynab.GetCategory(categoryGroup, categoryName)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							amountString := record[4]
 | 
				
			||||||
 | 
							amount, err := GetAmount(amountString, "0,00€")
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not parse amount %s: %w", amountString, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if amount.Int.Int64() == 0 {
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							assignment := CreateAssignmentParams{
 | 
				
			||||||
 | 
								Date:       date,
 | 
				
			||||||
 | 
								CategoryID: category.UUID,
 | 
				
			||||||
 | 
								Amount:     amount,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							_, err = ynab.queries.CreateAssignment(ynab.Context, assignment)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not save assignment %v: %w", assignment, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							count++
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						fmt.Printf("Imported %d assignments\n", count)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Transfer struct {
 | 
				
			||||||
 | 
						CreateTransactionParams
 | 
				
			||||||
 | 
						TransferToAccount *Account
 | 
				
			||||||
 | 
						FromAccount       string
 | 
				
			||||||
 | 
						ToAccount         string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ImportTransactions expects a TSV-file as exported by YNAB in the following format:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
 | 
				
			||||||
 | 
						csv := csv.NewReader(r)
 | 
				
			||||||
 | 
						csv.Comma = '\t'
 | 
				
			||||||
 | 
						csv.LazyQuotes = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						csvData, err := csv.ReadAll()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return fmt.Errorf("could not read from tsv: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var openTransfers []Transfer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						count := 0
 | 
				
			||||||
 | 
						for _, record := range csvData[1:] {
 | 
				
			||||||
 | 
							accountName := record[0]
 | 
				
			||||||
 | 
							account, err := ynab.GetAccount(accountName)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not get account %s: %w", accountName, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							//flag := record[1]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							dateString := record[2]
 | 
				
			||||||
 | 
							date, err := time.Parse("02.01.2006", dateString)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not parse date %s: %w", dateString, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							categoryGroup, categoryName := record[5], record[6] //also in 4 joined by :
 | 
				
			||||||
 | 
							category, err := ynab.GetCategory(categoryGroup, categoryName)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							memo := record[7]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							outflow := record[8]
 | 
				
			||||||
 | 
							inflow := record[9]
 | 
				
			||||||
 | 
							amount, err := GetAmount(inflow, outflow)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							transaction := CreateTransactionParams{
 | 
				
			||||||
 | 
								Date:       date,
 | 
				
			||||||
 | 
								Memo:       memo,
 | 
				
			||||||
 | 
								AccountID:  account.ID,
 | 
				
			||||||
 | 
								CategoryID: category,
 | 
				
			||||||
 | 
								Amount:     amount,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							payeeName := record[3]
 | 
				
			||||||
 | 
							if strings.HasPrefix(payeeName, "Transfer : ") {
 | 
				
			||||||
 | 
								// Transaction is a transfer to
 | 
				
			||||||
 | 
								transferToAccountName := payeeName[11:]
 | 
				
			||||||
 | 
								transferToAccount, err := ynab.GetAccount(transferToAccountName)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								transfer := Transfer{
 | 
				
			||||||
 | 
									transaction,
 | 
				
			||||||
 | 
									transferToAccount,
 | 
				
			||||||
 | 
									accountName,
 | 
				
			||||||
 | 
									transferToAccountName,
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								found := false
 | 
				
			||||||
 | 
								for i, openTransfer := range openTransfers {
 | 
				
			||||||
 | 
									if openTransfer.TransferToAccount.ID != transfer.AccountID {
 | 
				
			||||||
 | 
										continue
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									if openTransfer.AccountID != transfer.TransferToAccount.ID {
 | 
				
			||||||
 | 
										continue
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									if openTransfer.Amount.GetFloat64() != -1*transfer.Amount.GetFloat64() {
 | 
				
			||||||
 | 
										continue
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
 | 
				
			||||||
 | 
									openTransfers[i] = openTransfers[len(openTransfers)-1]
 | 
				
			||||||
 | 
									openTransfers = openTransfers[:len(openTransfers)-1]
 | 
				
			||||||
 | 
									found = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									groupID := uuid.New()
 | 
				
			||||||
 | 
									transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
 | 
				
			||||||
 | 
									openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									_, err = ynab.queries.CreateTransaction(ynab.Context, transfer.CreateTransactionParams)
 | 
				
			||||||
 | 
									if err != nil {
 | 
				
			||||||
 | 
										return fmt.Errorf("could not save transaction %v: %w", transfer.CreateTransactionParams, err)
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
 | 
				
			||||||
 | 
									if err != nil {
 | 
				
			||||||
 | 
										return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									break
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if !found {
 | 
				
			||||||
 | 
									openTransfers = append(openTransfers, transfer)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								payeeID, err := ynab.GetPayee(payeeName)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return fmt.Errorf("could not get payee %s: %w", payeeName, err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								transaction.PayeeID = payeeID
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return fmt.Errorf("could not save transaction %v: %w", transaction, err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							//status := record[10]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							count++
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, openTransfer := range openTransfers {
 | 
				
			||||||
 | 
							fmt.Printf("Saving unmatched transfer from %s to %s on %s over %f as regular transaction\n", openTransfer.FromAccount, openTransfer.ToAccount, openTransfer.Date, openTransfer.Amount.GetFloat64())
 | 
				
			||||||
 | 
							_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						fmt.Printf("Imported %d transactions\n", count)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func trimLastChar(s string) string {
 | 
				
			||||||
 | 
						r, size := utf8.DecodeLastRuneInString(s)
 | 
				
			||||||
 | 
						if r == utf8.RuneError && (size == 0 || size == 1) {
 | 
				
			||||||
 | 
							size = 0
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return s[:len(s)-size]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func GetAmount(inflow string, outflow string) (Numeric, error) {
 | 
				
			||||||
 | 
						// Remove trailing currency
 | 
				
			||||||
 | 
						inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
 | 
				
			||||||
 | 
						outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						num := Numeric{}
 | 
				
			||||||
 | 
						err := num.Set(inflow)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// if inflow is zero, use outflow
 | 
				
			||||||
 | 
						if num.Int.Int64() != 0 {
 | 
				
			||||||
 | 
							return num, nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err = num.Set("-" + outflow)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return num, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ynab *YNABImport) GetAccount(name string) (*Account, error) {
 | 
				
			||||||
 | 
						for _, acc := range ynab.accounts {
 | 
				
			||||||
 | 
							if acc.Name == name {
 | 
				
			||||||
 | 
								return &acc, nil
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						account, err := ynab.queries.CreateAccount(ynab.Context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						ynab.accounts = append(ynab.accounts, account)
 | 
				
			||||||
 | 
						return &account, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
 | 
				
			||||||
 | 
						if name == "" {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, pay := range ynab.payees {
 | 
				
			||||||
 | 
							if pay.Name == name {
 | 
				
			||||||
 | 
								return uuid.NullUUID{UUID: pay.ID, Valid: true}, nil
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						payee, err := ynab.queries.CreatePayee(ynab.Context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						ynab.payees = append(ynab.payees, payee)
 | 
				
			||||||
 | 
						return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
 | 
				
			||||||
 | 
						if group == "" || name == "" {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, category := range ynab.categories {
 | 
				
			||||||
 | 
							if category.Name == name && category.Group == group {
 | 
				
			||||||
 | 
								return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, categoryGroup := range ynab.categoryGroups {
 | 
				
			||||||
 | 
							if categoryGroup.Name == group {
 | 
				
			||||||
 | 
								createCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
 | 
				
			||||||
 | 
								category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									return uuid.NullUUID{}, err
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								getCategory := GetCategoriesRow{
 | 
				
			||||||
 | 
									ID:              category.ID,
 | 
				
			||||||
 | 
									CategoryGroupID: category.CategoryGroupID,
 | 
				
			||||||
 | 
									Name:            category.Name,
 | 
				
			||||||
 | 
									Group:           categoryGroup.Name,
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								ynab.categories = append(ynab.categories, getCategory)
 | 
				
			||||||
 | 
								return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						category, err := ynab.queries.CreateCategory(ynab.Context, CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return uuid.NullUUID{}, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						getCategory := GetCategoriesRow{
 | 
				
			||||||
 | 
							ID:              category.ID,
 | 
				
			||||||
 | 
							CategoryGroupID: category.CategoryGroupID,
 | 
				
			||||||
 | 
							Name:            category.Name,
 | 
				
			||||||
 | 
							Group:           categoryGroup.Name,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						ynab.categories = append(ynab.categories, getCategory)
 | 
				
			||||||
 | 
						return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,18 +3,18 @@
 | 
				
			|||||||
{{define "title"}}{{.Account.Name}}{{end}}
 | 
					{{define "title"}}{{.Account.Name}}{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{{define "new"}}
 | 
					{{define "new"}}
 | 
				
			||||||
    {{template "transaction-new"}}
 | 
					    {{template "transaction-new" .}}
 | 
				
			||||||
{{end}}
 | 
					{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{{define "main"}}
 | 
					{{define "main"}}
 | 
				
			||||||
<div class="budget-item">
 | 
					<div class="budget-item">
 | 
				
			||||||
    <a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
 | 
					    <a href="#newtransactionmodal" data-bs-toggle="modal" data-bs-target="#newtransactionmodal">New Transaction</a>
 | 
				
			||||||
    <span class="time"></span>
 | 
					    <span class="time"></span>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
<table class="container col-lg-12" id="content">
 | 
					<table class="container col-lg-12" id="content">
 | 
				
			||||||
    {{range .Transactions}}
 | 
					    {{range .Transactions}}
 | 
				
			||||||
    <tr>
 | 
					    <tr class="{{if .Date.After now}}future{{end}}">
 | 
				
			||||||
        <td>{{.Date}}</td>
 | 
					        <td>{{.Date.Format "02.01.2006"}}</td>
 | 
				
			||||||
        <td>
 | 
					        <td>
 | 
				
			||||||
            {{.Account}}
 | 
					            {{.Account}}
 | 
				
			||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
@@ -27,7 +27,10 @@
 | 
				
			|||||||
            {{end}}
 | 
					            {{end}}
 | 
				
			||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
        <td>
 | 
					        <td>
 | 
				
			||||||
            <a href="transaction/{{.ID}}">{{.Memo}}</a>
 | 
					            {{if .GroupID.Valid}}☀{{end}}
 | 
				
			||||||
 | 
					        </td>
 | 
				
			||||||
 | 
					        <td>
 | 
				
			||||||
 | 
					            <a href="/budget/{{$.Budget.ID}}/transaction/{{.ID}}">{{.Memo}}</a>
 | 
				
			||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
        {{template "amount-cell" .Amount}}
 | 
					        {{template "amount-cell" .Amount}}
 | 
				
			||||||
    </tr>
 | 
					    </tr>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,23 @@
 | 
				
			|||||||
{{define "amount"}}
 | 
					{{define "amount"}}
 | 
				
			||||||
        <span class="right {{if .GetPositive}}{{else}}negative{{end}}">
 | 
					        <span class="right {{if .IsZero}}zero{{else if not .IsPositive}}negative{{end}}">
 | 
				
			||||||
            {{printf "%.2f" .GetFloat64}}
 | 
					            {{printf "%.2f" .GetFloat64}}
 | 
				
			||||||
        </span>
 | 
					        </span>
 | 
				
			||||||
{{end}}
 | 
					{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{{define "amount-cell"}}
 | 
					{{define "amount-cell"}}
 | 
				
			||||||
        <td class="right {{if .GetPositive}}{{else}}negative{{end}}">
 | 
					        <td class="right {{if .IsZero}}zero{{else if not .IsPositive}}negative{{end}}">
 | 
				
			||||||
            {{printf "%.2f" .GetFloat64}}
 | 
					            {{printf "%.2f" .GetFloat64}}
 | 
				
			||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
{{end}}
 | 
					{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{{define "amountf64"}}
 | 
				
			||||||
 | 
					        <span class="right {{if eq . 0.0}}zero{{else if lt . 0.0}}negative{{end}}">
 | 
				
			||||||
 | 
					            {{printf "%.2f" .}}
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{{define "amountf64-cell"}}
 | 
				
			||||||
 | 
					        <td class="right {{if eq . 0.0}}zero{{else if lt . 0.0}}negative{{end}}">
 | 
				
			||||||
 | 
					            {{printf "%.2f" .}}
 | 
				
			||||||
 | 
					        </td>
 | 
				
			||||||
 | 
					{{end}}
 | 
				
			||||||
@@ -6,7 +6,6 @@
 | 
				
			|||||||
	    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
 | 
						    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	    <link href="/static/css/bootstrap.min.css" rel="stylesheet" />
 | 
						    <link href="/static/css/bootstrap.min.css" rel="stylesheet" />
 | 
				
			||||||
	    <link href="/static/css/bootstrap-theme.min.css" rel="stylesheet" />
 | 
					 | 
				
			||||||
	    <link href="/static/css/main.css" rel="stylesheet" />
 | 
						    <link href="/static/css/main.css" rel="stylesheet" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> 
 | 
						    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> 
 | 
				
			||||||
@@ -19,6 +18,7 @@
 | 
				
			|||||||
        {{block "more-head" .}}{{end}}
 | 
					        {{block "more-head" .}}{{end}}
 | 
				
			||||||
    </head>
 | 
					    </head>
 | 
				
			||||||
    <body>
 | 
					    <body>
 | 
				
			||||||
 | 
					        <div id="wrapper">
 | 
				
			||||||
            <div id="sidebar">
 | 
					            <div id="sidebar">
 | 
				
			||||||
                {{block "sidebar" .}}
 | 
					                {{block "sidebar" .}}
 | 
				
			||||||
                    {{template "budget-sidebar" .}}
 | 
					                    {{template "budget-sidebar" .}}
 | 
				
			||||||
@@ -33,6 +33,7 @@
 | 
				
			|||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
                {{block "new" .}}{{end}}
 | 
					                {{block "new" .}}{{end}}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
    </body>
 | 
					    </body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
{{end}}
 | 
					{{end}}
 | 
				
			||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
{{define "budget-new"}}
 | 
					{{define "budget-new"}}
 | 
				
			||||||
    <div id="newbudgetmodal" class="modal fade">
 | 
					    <div id="newbudgetmodal" class="modal fade" role="dialog">
 | 
				
			||||||
        <div class="modal-dialog" role="document">
 | 
					        <div class="modal-dialog" role="document">
 | 
				
			||||||
            <script>        
 | 
					            <script>        
 | 
				
			||||||
                $(document).ready(function () {
 | 
					                $(document).ready(function () {
 | 
				
			||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
            <div class="modal-content">
 | 
					            <div class="modal-content">
 | 
				
			||||||
                <div class="modal-header">
 | 
					                <div class="modal-header">
 | 
				
			||||||
                    <h5 class="modal-title">New Budget</h5>
 | 
					                    <h5 class="modal-title">New Budget</h5>
 | 
				
			||||||
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
 | 
					                    <button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
 | 
				
			||||||
                        <span aria-hidden="true">×</span>
 | 
					                        <span aria-hidden="true">×</span>
 | 
				
			||||||
                    </button>
 | 
					                    </button>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
@@ -30,7 +30,7 @@
 | 
				
			|||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                    <div class="modal-footer">
 | 
					                    <div class="modal-footer">
 | 
				
			||||||
                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
 | 
					                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
 | 
				
			||||||
                        <input type="submit" class="btn btn-primary" value="Create" class="form-control" />
 | 
					                        <input type="submit" class="btn btn-primary" value="Create" class="form-control" />
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                </form>
 | 
					                </form>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
{{define "budget-sidebar"}}
 | 
					{{define "budget-sidebar"}}
 | 
				
			||||||
 | 
					<h1><a href="/dashboard">⌂</a> {{.Budget.Name}}</h1>
 | 
				
			||||||
<ul>
 | 
					<ul>
 | 
				
			||||||
        <li><a href="/budget/{{.Budget.ID}}">Budget</a></li>
 | 
					        <li><a href="/budget/{{.Budget.ID}}">Budget</a></li>
 | 
				
			||||||
        <li>Reports (Coming Soon)</li>
 | 
					        <li>Reports (Coming Soon)</li>
 | 
				
			||||||
@@ -6,28 +7,42 @@
 | 
				
			|||||||
        <li>
 | 
					        <li>
 | 
				
			||||||
                On-Budget Accounts
 | 
					                On-Budget Accounts
 | 
				
			||||||
                <ul class="two-valued">
 | 
					                <ul class="two-valued">
 | 
				
			||||||
                        {{range .Accounts}}
 | 
					                        {{- range .OnBudgetAccounts}}
 | 
				
			||||||
                        <li>
 | 
					                        <li>
 | 
				
			||||||
                                <a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
 | 
					                                <a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
 | 
				
			||||||
                                        {{template "amount" .Balance}}
 | 
					                                {{- template "amount" .Balance}}
 | 
				
			||||||
                                </li>
 | 
					 | 
				
			||||||
                        {{end}}
 | 
					 | 
				
			||||||
                        <li>
 | 
					 | 
				
			||||||
                                <a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
 | 
					 | 
				
			||||||
                        </li>
 | 
					                        </li>
 | 
				
			||||||
 | 
					                        {{- end}}
 | 
				
			||||||
                </ul>
 | 
					                </ul>
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
        <li>
 | 
					        <li>
 | 
				
			||||||
                Off-Budget Accounts
 | 
					                Off-Budget Accounts
 | 
				
			||||||
 | 
					                <ul class="two-valued">
 | 
				
			||||||
 | 
					                        {{- range .OffBudgetAccounts}}
 | 
				
			||||||
 | 
					                                <li>
 | 
				
			||||||
 | 
					                                        <a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
 | 
				
			||||||
 | 
					                                        {{template "amount" .Balance -}}
 | 
				
			||||||
 | 
					                                </li>
 | 
				
			||||||
 | 
					                        {{- end}}
 | 
				
			||||||
 | 
					                </ul>
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
        <li>
 | 
					        <li>
 | 
				
			||||||
                Closed Accounts
 | 
					                Closed Accounts
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
 | 
					        <li>
 | 
				
			||||||
 | 
					                <a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
 | 
				
			||||||
 | 
					        </li>
 | 
				
			||||||
        <li>
 | 
					        <li>
 | 
				
			||||||
                + Add Account
 | 
					                + Add Account
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
        <li>
 | 
					        <li>
 | 
				
			||||||
                <a href="/admin">Settings</a>
 | 
					                <a href="/budget/{{.Budget.ID}}/settings">Budget-Settings</a>
 | 
				
			||||||
 | 
					        </li>
 | 
				
			||||||
 | 
					        <li>
 | 
				
			||||||
 | 
					                <a href="/admin">Admin</a>
 | 
				
			||||||
 | 
					        </li>
 | 
				
			||||||
 | 
					        <li>
 | 
				
			||||||
 | 
					                <a href="/api/v1/user/logout">Logout</a>
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
</ul>        
 | 
					</ul>        
 | 
				
			||||||
{{end}}
 | 
					{{end}}
 | 
				
			||||||
@@ -1,31 +0,0 @@
 | 
				
			|||||||
{{template "base" .}}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{{define "title"}}Budget{{end}}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{{define "new"}}
 | 
					 | 
				
			||||||
    {{template "transaction-new"}}
 | 
					 | 
				
			||||||
{{end}}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{{define "main"}}
 | 
					 | 
				
			||||||
<div class="budget-item">
 | 
					 | 
				
			||||||
    <a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
 | 
					 | 
				
			||||||
    <span class="time"></span>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
<table class="container col-lg-12" id="content">
 | 
					 | 
				
			||||||
    {{range .Transactions}}
 | 
					 | 
				
			||||||
    <tr>
 | 
					 | 
				
			||||||
        <td>{{.Date}}</td>
 | 
					 | 
				
			||||||
        <td>
 | 
					 | 
				
			||||||
            {{.Account}}
 | 
					 | 
				
			||||||
        </td>
 | 
					 | 
				
			||||||
        <td>
 | 
					 | 
				
			||||||
            {{.Payee}}
 | 
					 | 
				
			||||||
        </td>
 | 
					 | 
				
			||||||
        <td>
 | 
					 | 
				
			||||||
            <a href="transaction/{{.ID}}">{{.Memo}}</a>
 | 
					 | 
				
			||||||
        </td>
 | 
					 | 
				
			||||||
        {{template "amount-cell" .Amount}}
 | 
					 | 
				
			||||||
    </tr>
 | 
					 | 
				
			||||||
    {{end}}
 | 
					 | 
				
			||||||
</table>
 | 
					 | 
				
			||||||
{{end}}
 | 
					 | 
				
			||||||
@@ -6,12 +6,11 @@
 | 
				
			|||||||
{{end}}
 | 
					{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{{define "new"}}
 | 
					{{define "new"}}
 | 
				
			||||||
    {{template "transaction-new"}}
 | 
					 | 
				
			||||||
{{end}}
 | 
					{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{{define "main"}}
 | 
					{{define "main"}}
 | 
				
			||||||
<div class="budget-item">
 | 
					<div class="budget-item">
 | 
				
			||||||
    <a href="#newtransactionmodal" data-toggle="modal" data-target="#newtransactionmodal">New Transaction</a>
 | 
					    <a href="#newtransactionmodal" data-bs-toggle="modal" data-bs-target="#newtransactionmodal">New Transaction</a>
 | 
				
			||||||
    <span class="time"></span>
 | 
					    <span class="time"></span>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
<div>
 | 
					<div>
 | 
				
			||||||
@@ -19,14 +18,19 @@
 | 
				
			|||||||
    <a href="{{printf "/budget/%s" .Budget.ID}}">Current Month</a> - 
 | 
					    <a href="{{printf "/budget/%s" .Budget.ID}}">Current Month</a> - 
 | 
				
			||||||
    <a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Next.Year .Next.Month}}">Next Month</a>
 | 
					    <a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Next.Year .Next.Month}}">Next Month</a>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					<div>
 | 
				
			||||||
 | 
					    <span>Available Balance: </span>{{template "amountf64" .AvailableBalance}}
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
<table class="container col-lg-12" id="content">
 | 
					<table class="container col-lg-12" id="content">
 | 
				
			||||||
    <tr>
 | 
					    <tr>
 | 
				
			||||||
        <th>Group</th>
 | 
					        <th>Group</th>
 | 
				
			||||||
        <th>Category</th>
 | 
					        <th>Category</th>
 | 
				
			||||||
        <th></th>
 | 
					        <th></th>
 | 
				
			||||||
        <th></th>
 | 
					        <th></th>
 | 
				
			||||||
        <th>Balance</th>
 | 
					        <th>Leftover</th>
 | 
				
			||||||
 | 
					        <th>Assigned</th>
 | 
				
			||||||
        <th>Activity</th>
 | 
					        <th>Activity</th>
 | 
				
			||||||
 | 
					        <th>Available</th>
 | 
				
			||||||
    </tr>
 | 
					    </tr>
 | 
				
			||||||
    {{range .Categories}}
 | 
					    {{range .Categories}}
 | 
				
			||||||
    <tr>
 | 
					    <tr>
 | 
				
			||||||
@@ -36,8 +40,10 @@
 | 
				
			|||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
        <td>
 | 
					        <td>
 | 
				
			||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
        {{template "amount-cell" .Balance}}
 | 
					        {{template "amountf64-cell" .AvailableLastMonth}}
 | 
				
			||||||
        {{template "amount-cell" .Activity}}
 | 
					        {{template "amountf64-cell" .Assigned}}
 | 
				
			||||||
 | 
					        {{template "amountf64-cell" .Activity}}
 | 
				
			||||||
 | 
					        {{template "amountf64-cell" .Available}}
 | 
				
			||||||
    </tr>
 | 
					    </tr>
 | 
				
			||||||
    {{end}}
 | 
					    {{end}}
 | 
				
			||||||
</table>
 | 
					</table>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,7 +20,7 @@
 | 
				
			|||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    {{end}}
 | 
					    {{end}}
 | 
				
			||||||
    <div class="budget-item">
 | 
					    <div class="budget-item">
 | 
				
			||||||
        <a href="#newbudgetmodal" data-toggle="modal" data-target="#newbudgetmodal">New Budget</a>
 | 
					        <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newbudgetmodal">New Budget</a>
 | 
				
			||||||
        <span class="time"></span>
 | 
					        <span class="time"></span>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
{{end}}
 | 
					{{end}}
 | 
				
			||||||
							
								
								
									
										32
									
								
								web/settings.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								web/settings.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
				
			|||||||
 | 
					{{define "title"}}
 | 
				
			||||||
 | 
					    Settings for Budget "{{.Budget.Name}}"
 | 
				
			||||||
 | 
					{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{{template "base" .}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{{define "main"}}
 | 
				
			||||||
 | 
					    <h1>Danger Zone</h1>
 | 
				
			||||||
 | 
					    <div class="budget-item">
 | 
				
			||||||
 | 
					        <a href="/budget/{{.Budget.ID}}/settings/clear">Clear database</a>
 | 
				
			||||||
 | 
					        <p>This removes all data and starts from scratch. Not undoable!</p>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="budget-item">
 | 
				
			||||||
 | 
					        <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
 | 
				
			||||||
 | 
					        <p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="budget-item">
 | 
				
			||||||
 | 
					        <form method="POST" action="/api/v1/transaction/import/ynab" enctype="multipart/form-data">
 | 
				
			||||||
 | 
					            <input type="hidden" name="budget_id" value="{{.Budget.ID}}" />
 | 
				
			||||||
 | 
					            <label for="transactions_file">
 | 
				
			||||||
 | 
					                Transaktionen:
 | 
				
			||||||
 | 
					                <input type="file" name="transactions" accept="text/*" />
 | 
				
			||||||
 | 
					            </label>
 | 
				
			||||||
 | 
					            <br />
 | 
				
			||||||
 | 
					            <label for="assignments_file">
 | 
				
			||||||
 | 
					                Budget:
 | 
				
			||||||
 | 
					                <input type="file" name="assignments" accept="text/*" />
 | 
				
			||||||
 | 
					            </label>
 | 
				
			||||||
 | 
					            <button type="submit">Importieren</button>
 | 
				
			||||||
 | 
					        </form>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					{{end}}
 | 
				
			||||||
							
								
								
									
										587
									
								
								web/static/css/bootstrap-theme.css
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										587
									
								
								web/static/css/bootstrap-theme.css
									
									
									
									
										vendored
									
									
								
							@@ -1,587 +0,0 @@
 | 
				
			|||||||
/*!
 | 
					 | 
				
			||||||
 * Bootstrap v3.3.7 (http://getbootstrap.com)
 | 
					 | 
				
			||||||
 * Copyright 2011-2016 Twitter, Inc.
 | 
					 | 
				
			||||||
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
.btn-default,
 | 
					 | 
				
			||||||
.btn-primary,
 | 
					 | 
				
			||||||
.btn-success,
 | 
					 | 
				
			||||||
.btn-info,
 | 
					 | 
				
			||||||
.btn-warning,
 | 
					 | 
				
			||||||
.btn-danger {
 | 
					 | 
				
			||||||
  text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
 | 
					 | 
				
			||||||
  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-default:active,
 | 
					 | 
				
			||||||
.btn-primary:active,
 | 
					 | 
				
			||||||
.btn-success:active,
 | 
					 | 
				
			||||||
.btn-info:active,
 | 
					 | 
				
			||||||
.btn-warning:active,
 | 
					 | 
				
			||||||
.btn-danger:active,
 | 
					 | 
				
			||||||
.btn-default.active,
 | 
					 | 
				
			||||||
.btn-primary.active,
 | 
					 | 
				
			||||||
.btn-success.active,
 | 
					 | 
				
			||||||
.btn-info.active,
 | 
					 | 
				
			||||||
.btn-warning.active,
 | 
					 | 
				
			||||||
.btn-danger.active {
 | 
					 | 
				
			||||||
  -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
 | 
					 | 
				
			||||||
          box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-default.disabled,
 | 
					 | 
				
			||||||
.btn-primary.disabled,
 | 
					 | 
				
			||||||
.btn-success.disabled,
 | 
					 | 
				
			||||||
.btn-info.disabled,
 | 
					 | 
				
			||||||
.btn-warning.disabled,
 | 
					 | 
				
			||||||
.btn-danger.disabled,
 | 
					 | 
				
			||||||
.btn-default[disabled],
 | 
					 | 
				
			||||||
.btn-primary[disabled],
 | 
					 | 
				
			||||||
.btn-success[disabled],
 | 
					 | 
				
			||||||
.btn-info[disabled],
 | 
					 | 
				
			||||||
.btn-warning[disabled],
 | 
					 | 
				
			||||||
.btn-danger[disabled],
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-default,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-primary,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-success,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-info,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-warning,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-danger {
 | 
					 | 
				
			||||||
  -webkit-box-shadow: none;
 | 
					 | 
				
			||||||
          box-shadow: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-default .badge,
 | 
					 | 
				
			||||||
.btn-primary .badge,
 | 
					 | 
				
			||||||
.btn-success .badge,
 | 
					 | 
				
			||||||
.btn-info .badge,
 | 
					 | 
				
			||||||
.btn-warning .badge,
 | 
					 | 
				
			||||||
.btn-danger .badge {
 | 
					 | 
				
			||||||
  text-shadow: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn:active,
 | 
					 | 
				
			||||||
.btn.active {
 | 
					 | 
				
			||||||
  background-image: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-default {
 | 
					 | 
				
			||||||
  text-shadow: 0 1px 0 #fff;
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #dbdbdb;
 | 
					 | 
				
			||||||
  border-color: #ccc;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-default:hover,
 | 
					 | 
				
			||||||
.btn-default:focus {
 | 
					 | 
				
			||||||
  background-color: #e0e0e0;
 | 
					 | 
				
			||||||
  background-position: 0 -15px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-default:active,
 | 
					 | 
				
			||||||
.btn-default.active {
 | 
					 | 
				
			||||||
  background-color: #e0e0e0;
 | 
					 | 
				
			||||||
  border-color: #dbdbdb;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-default.disabled,
 | 
					 | 
				
			||||||
.btn-default[disabled],
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-default,
 | 
					 | 
				
			||||||
.btn-default.disabled:hover,
 | 
					 | 
				
			||||||
.btn-default[disabled]:hover,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-default:hover,
 | 
					 | 
				
			||||||
.btn-default.disabled:focus,
 | 
					 | 
				
			||||||
.btn-default[disabled]:focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-default:focus,
 | 
					 | 
				
			||||||
.btn-default.disabled.focus,
 | 
					 | 
				
			||||||
.btn-default[disabled].focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-default.focus,
 | 
					 | 
				
			||||||
.btn-default.disabled:active,
 | 
					 | 
				
			||||||
.btn-default[disabled]:active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-default:active,
 | 
					 | 
				
			||||||
.btn-default.disabled.active,
 | 
					 | 
				
			||||||
.btn-default[disabled].active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-default.active {
 | 
					 | 
				
			||||||
  background-color: #e0e0e0;
 | 
					 | 
				
			||||||
  background-image: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-primary {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #245580;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-primary:hover,
 | 
					 | 
				
			||||||
.btn-primary:focus {
 | 
					 | 
				
			||||||
  background-color: #265a88;
 | 
					 | 
				
			||||||
  background-position: 0 -15px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-primary:active,
 | 
					 | 
				
			||||||
.btn-primary.active {
 | 
					 | 
				
			||||||
  background-color: #265a88;
 | 
					 | 
				
			||||||
  border-color: #245580;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-primary.disabled,
 | 
					 | 
				
			||||||
.btn-primary[disabled],
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-primary,
 | 
					 | 
				
			||||||
.btn-primary.disabled:hover,
 | 
					 | 
				
			||||||
.btn-primary[disabled]:hover,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-primary:hover,
 | 
					 | 
				
			||||||
.btn-primary.disabled:focus,
 | 
					 | 
				
			||||||
.btn-primary[disabled]:focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-primary:focus,
 | 
					 | 
				
			||||||
.btn-primary.disabled.focus,
 | 
					 | 
				
			||||||
.btn-primary[disabled].focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-primary.focus,
 | 
					 | 
				
			||||||
.btn-primary.disabled:active,
 | 
					 | 
				
			||||||
.btn-primary[disabled]:active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-primary:active,
 | 
					 | 
				
			||||||
.btn-primary.disabled.active,
 | 
					 | 
				
			||||||
.btn-primary[disabled].active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-primary.active {
 | 
					 | 
				
			||||||
  background-color: #265a88;
 | 
					 | 
				
			||||||
  background-image: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-success {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #3e8f3e;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-success:hover,
 | 
					 | 
				
			||||||
.btn-success:focus {
 | 
					 | 
				
			||||||
  background-color: #419641;
 | 
					 | 
				
			||||||
  background-position: 0 -15px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-success:active,
 | 
					 | 
				
			||||||
.btn-success.active {
 | 
					 | 
				
			||||||
  background-color: #419641;
 | 
					 | 
				
			||||||
  border-color: #3e8f3e;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-success.disabled,
 | 
					 | 
				
			||||||
.btn-success[disabled],
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-success,
 | 
					 | 
				
			||||||
.btn-success.disabled:hover,
 | 
					 | 
				
			||||||
.btn-success[disabled]:hover,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-success:hover,
 | 
					 | 
				
			||||||
.btn-success.disabled:focus,
 | 
					 | 
				
			||||||
.btn-success[disabled]:focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-success:focus,
 | 
					 | 
				
			||||||
.btn-success.disabled.focus,
 | 
					 | 
				
			||||||
.btn-success[disabled].focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-success.focus,
 | 
					 | 
				
			||||||
.btn-success.disabled:active,
 | 
					 | 
				
			||||||
.btn-success[disabled]:active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-success:active,
 | 
					 | 
				
			||||||
.btn-success.disabled.active,
 | 
					 | 
				
			||||||
.btn-success[disabled].active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-success.active {
 | 
					 | 
				
			||||||
  background-color: #419641;
 | 
					 | 
				
			||||||
  background-image: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-info {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #28a4c9;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-info:hover,
 | 
					 | 
				
			||||||
.btn-info:focus {
 | 
					 | 
				
			||||||
  background-color: #2aabd2;
 | 
					 | 
				
			||||||
  background-position: 0 -15px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-info:active,
 | 
					 | 
				
			||||||
.btn-info.active {
 | 
					 | 
				
			||||||
  background-color: #2aabd2;
 | 
					 | 
				
			||||||
  border-color: #28a4c9;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-info.disabled,
 | 
					 | 
				
			||||||
.btn-info[disabled],
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-info,
 | 
					 | 
				
			||||||
.btn-info.disabled:hover,
 | 
					 | 
				
			||||||
.btn-info[disabled]:hover,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-info:hover,
 | 
					 | 
				
			||||||
.btn-info.disabled:focus,
 | 
					 | 
				
			||||||
.btn-info[disabled]:focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-info:focus,
 | 
					 | 
				
			||||||
.btn-info.disabled.focus,
 | 
					 | 
				
			||||||
.btn-info[disabled].focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-info.focus,
 | 
					 | 
				
			||||||
.btn-info.disabled:active,
 | 
					 | 
				
			||||||
.btn-info[disabled]:active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-info:active,
 | 
					 | 
				
			||||||
.btn-info.disabled.active,
 | 
					 | 
				
			||||||
.btn-info[disabled].active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-info.active {
 | 
					 | 
				
			||||||
  background-color: #2aabd2;
 | 
					 | 
				
			||||||
  background-image: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-warning {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #e38d13;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-warning:hover,
 | 
					 | 
				
			||||||
.btn-warning:focus {
 | 
					 | 
				
			||||||
  background-color: #eb9316;
 | 
					 | 
				
			||||||
  background-position: 0 -15px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-warning:active,
 | 
					 | 
				
			||||||
.btn-warning.active {
 | 
					 | 
				
			||||||
  background-color: #eb9316;
 | 
					 | 
				
			||||||
  border-color: #e38d13;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-warning.disabled,
 | 
					 | 
				
			||||||
.btn-warning[disabled],
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-warning,
 | 
					 | 
				
			||||||
.btn-warning.disabled:hover,
 | 
					 | 
				
			||||||
.btn-warning[disabled]:hover,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-warning:hover,
 | 
					 | 
				
			||||||
.btn-warning.disabled:focus,
 | 
					 | 
				
			||||||
.btn-warning[disabled]:focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-warning:focus,
 | 
					 | 
				
			||||||
.btn-warning.disabled.focus,
 | 
					 | 
				
			||||||
.btn-warning[disabled].focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-warning.focus,
 | 
					 | 
				
			||||||
.btn-warning.disabled:active,
 | 
					 | 
				
			||||||
.btn-warning[disabled]:active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-warning:active,
 | 
					 | 
				
			||||||
.btn-warning.disabled.active,
 | 
					 | 
				
			||||||
.btn-warning[disabled].active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-warning.active {
 | 
					 | 
				
			||||||
  background-color: #eb9316;
 | 
					 | 
				
			||||||
  background-image: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-danger {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #b92c28;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-danger:hover,
 | 
					 | 
				
			||||||
.btn-danger:focus {
 | 
					 | 
				
			||||||
  background-color: #c12e2a;
 | 
					 | 
				
			||||||
  background-position: 0 -15px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-danger:active,
 | 
					 | 
				
			||||||
.btn-danger.active {
 | 
					 | 
				
			||||||
  background-color: #c12e2a;
 | 
					 | 
				
			||||||
  border-color: #b92c28;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.btn-danger.disabled,
 | 
					 | 
				
			||||||
.btn-danger[disabled],
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-danger,
 | 
					 | 
				
			||||||
.btn-danger.disabled:hover,
 | 
					 | 
				
			||||||
.btn-danger[disabled]:hover,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-danger:hover,
 | 
					 | 
				
			||||||
.btn-danger.disabled:focus,
 | 
					 | 
				
			||||||
.btn-danger[disabled]:focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-danger:focus,
 | 
					 | 
				
			||||||
.btn-danger.disabled.focus,
 | 
					 | 
				
			||||||
.btn-danger[disabled].focus,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-danger.focus,
 | 
					 | 
				
			||||||
.btn-danger.disabled:active,
 | 
					 | 
				
			||||||
.btn-danger[disabled]:active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-danger:active,
 | 
					 | 
				
			||||||
.btn-danger.disabled.active,
 | 
					 | 
				
			||||||
.btn-danger[disabled].active,
 | 
					 | 
				
			||||||
fieldset[disabled] .btn-danger.active {
 | 
					 | 
				
			||||||
  background-color: #c12e2a;
 | 
					 | 
				
			||||||
  background-image: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.thumbnail,
 | 
					 | 
				
			||||||
.img-thumbnail {
 | 
					 | 
				
			||||||
  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
          box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.dropdown-menu > li > a:hover,
 | 
					 | 
				
			||||||
.dropdown-menu > li > a:focus {
 | 
					 | 
				
			||||||
  background-color: #e8e8e8;
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.dropdown-menu > .active > a,
 | 
					 | 
				
			||||||
.dropdown-menu > .active > a:hover,
 | 
					 | 
				
			||||||
.dropdown-menu > .active > a:focus {
 | 
					 | 
				
			||||||
  background-color: #2e6da4;
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-default {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-default .navbar-nav > .open > a,
 | 
					 | 
				
			||||||
.navbar-default .navbar-nav > .active > a {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
          box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-brand,
 | 
					 | 
				
			||||||
.navbar-nav > li > a {
 | 
					 | 
				
			||||||
  text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-inverse {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-inverse .navbar-nav > .open > a,
 | 
					 | 
				
			||||||
.navbar-inverse .navbar-nav > .active > a {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
 | 
					 | 
				
			||||||
          box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-inverse .navbar-brand,
 | 
					 | 
				
			||||||
.navbar-inverse .navbar-nav > li > a {
 | 
					 | 
				
			||||||
  text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-static-top,
 | 
					 | 
				
			||||||
.navbar-fixed-top,
 | 
					 | 
				
			||||||
.navbar-fixed-bottom {
 | 
					 | 
				
			||||||
  border-radius: 0;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@media (max-width: 767px) {
 | 
					 | 
				
			||||||
  .navbar .navbar-nav .open .dropdown-menu > .active > a,
 | 
					 | 
				
			||||||
  .navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
 | 
					 | 
				
			||||||
  .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
 | 
					 | 
				
			||||||
    color: #fff;
 | 
					 | 
				
			||||||
    background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
    background-image:      -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
    background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
 | 
					 | 
				
			||||||
    background-image:         linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
 | 
					 | 
				
			||||||
    background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.alert {
 | 
					 | 
				
			||||||
  text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
 | 
					 | 
				
			||||||
  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
 | 
					 | 
				
			||||||
          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.alert-success {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #b2dba1;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.alert-info {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #9acfea;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.alert-warning {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #f5e79e;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.alert-danger {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #dca7a7;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.progress {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.progress-bar {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #337ab7 0%, #286090 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #337ab7 0%, #286090 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.progress-bar-success {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.progress-bar-info {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.progress-bar-warning {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.progress-bar-danger {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.progress-bar-striped {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.list-group {
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
          box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.list-group-item.active,
 | 
					 | 
				
			||||||
.list-group-item.active:hover,
 | 
					 | 
				
			||||||
.list-group-item.active:focus {
 | 
					 | 
				
			||||||
  text-shadow: 0 -1px 0 #286090;
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #2b669a;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.list-group-item.active .badge,
 | 
					 | 
				
			||||||
.list-group-item.active:hover .badge,
 | 
					 | 
				
			||||||
.list-group-item.active:focus .badge {
 | 
					 | 
				
			||||||
  text-shadow: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.panel {
 | 
					 | 
				
			||||||
  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
 | 
					 | 
				
			||||||
          box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.panel-default > .panel-heading {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.panel-primary > .panel-heading {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.panel-success > .panel-heading {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.panel-info > .panel-heading {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.panel-warning > .panel-heading {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.panel-danger > .panel-heading {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.well {
 | 
					 | 
				
			||||||
  background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
 | 
					 | 
				
			||||||
  background-image:      -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
 | 
					 | 
				
			||||||
  background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
 | 
					 | 
				
			||||||
  background-image:         linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
 | 
					 | 
				
			||||||
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
 | 
					 | 
				
			||||||
  background-repeat: repeat-x;
 | 
					 | 
				
			||||||
  border-color: #dcdcdc;
 | 
					 | 
				
			||||||
  -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
 | 
					 | 
				
			||||||
          box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
/*# sourceMappingURL=bootstrap-theme.css.map */
 | 
					 | 
				
			||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										6
									
								
								web/static/css/bootstrap-theme.min.css
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								web/static/css/bootstrap-theme.min.css
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										6757
									
								
								web/static/css/bootstrap.css
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6757
									
								
								web/static/css/bootstrap.css
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										11
									
								
								web/static/css/bootstrap.min.css
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								web/static/css/bootstrap.min.css
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,3 +1,7 @@
 | 
				
			|||||||
 | 
					html {
 | 
				
			||||||
 | 
					    font-size: 16px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#head {
 | 
					#head {
 | 
				
			||||||
    height:160px;
 | 
					    height:160px;
 | 
				
			||||||
    line-height: 160px;
 | 
					    line-height: 160px;
 | 
				
			||||||
@@ -33,7 +37,7 @@
 | 
				
			|||||||
    font-size: 70.7%;
 | 
					    font-size: 70.7%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
body {
 | 
					#wrapper {
 | 
				
			||||||
    display: grid;
 | 
					    display: grid;
 | 
				
			||||||
    grid-template-columns: 300px auto;
 | 
					    grid-template-columns: 300px auto;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -61,6 +65,13 @@ body {
 | 
				
			|||||||
    text-align: right;
 | 
					    text-align: right;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Highlights */
 | 
				
			||||||
.negative {
 | 
					.negative {
 | 
				
			||||||
    color: #d50000;
 | 
					    color: #d50000;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					.zero {
 | 
				
			||||||
 | 
					    color: #888888;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.future {
 | 
				
			||||||
 | 
					    background-color: #cccccc;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										2377
									
								
								web/static/js/bootstrap.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2377
									
								
								web/static/js/bootstrap.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										10
									
								
								web/static/js/bootstrap.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								web/static/js/bootstrap.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								web/static/js/bootstrap.min.js.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/static/js/bootstrap.min.js.map
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -14,15 +14,25 @@
 | 
				
			|||||||
            <div class="modal-content">
 | 
					            <div class="modal-content">
 | 
				
			||||||
                <div class="modal-header">
 | 
					                <div class="modal-header">
 | 
				
			||||||
                    <h5 class="modal-title">New Transaction</h5>
 | 
					                    <h5 class="modal-title">New Transaction</h5>
 | 
				
			||||||
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
 | 
					                    <button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
 | 
				
			||||||
                        <span aria-hidden="true">×</span>
 | 
					                        <span aria-hidden="true">×</span>
 | 
				
			||||||
                    </button>
 | 
					                    </button>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
                <form id="newtransactionform" action="/api/v1/transaction/new" method="POST">
 | 
					                <form id="newtransactionform" action="/api/v1/transaction/new" method="POST">
 | 
				
			||||||
                    <div class="modal-body">
 | 
					                    <div class="modal-body">
 | 
				
			||||||
 | 
					                        <input type="hidden" name="account_id" value="{{.Account.ID}}" />
 | 
				
			||||||
 | 
					                        <div class="form-group">
 | 
				
			||||||
 | 
					                            <label for="category_id">Category</label>
 | 
				
			||||||
 | 
					                            <select name="category_id" class="form-control">
 | 
				
			||||||
 | 
					                                <option value="">-- none --</option>
 | 
				
			||||||
 | 
					                                {{range .Categories}}
 | 
				
			||||||
 | 
					                                    <option value="{{.ID}}">{{.Group}} : {{.Name}}</option>
 | 
				
			||||||
 | 
					                                {{end}}
 | 
				
			||||||
 | 
					                            </select>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
                        <div class="form-group">
 | 
					                        <div class="form-group">
 | 
				
			||||||
                            <label for="date">Date</label>
 | 
					                            <label for="date">Date</label>
 | 
				
			||||||
                            <input type="date" name="date" class="form-control" value="{{.Now}}" />
 | 
					                            <input type="date" name="date" class="form-control" value="{{now.Format "2006-01-02"}}" />
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                        <div class="form-group">
 | 
					                        <div class="form-group">
 | 
				
			||||||
                            <label for="memo">Memo</label>
 | 
					                            <label for="memo">Memo</label>
 | 
				
			||||||
@@ -34,7 +44,7 @@
 | 
				
			|||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                    <div class="modal-footer">
 | 
					                    <div class="modal-footer">
 | 
				
			||||||
                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
 | 
					                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
 | 
				
			||||||
                        <input type="submit" class="btn btn-primary" value="Create" class="form-control" />
 | 
					                        <input type="submit" class="btn btn-primary" value="Create" class="form-control" />
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                </form>
 | 
					                </form>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										64
									
								
								web/transaction.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								web/transaction.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,64 @@
 | 
				
			|||||||
 | 
					{{template "base" .}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{{define "title"}}{{.Account.Name}}{{end}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{{define "main"}}
 | 
				
			||||||
 | 
					<div>
 | 
				
			||||||
 | 
					    <script>        
 | 
				
			||||||
 | 
					        $(document).ready(function () {
 | 
				
			||||||
 | 
					            $('#errorcreatingtransaction').hide();
 | 
				
			||||||
 | 
					            $('#newtransactionform').ajaxForm({
 | 
				
			||||||
 | 
					                error: function() {
 | 
				
			||||||
 | 
					                    $('#errorcreatingtransaction').show();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }); 
 | 
				
			||||||
 | 
					        }); 
 | 
				
			||||||
 | 
					    </script>
 | 
				
			||||||
 | 
					    <div class="modal-header">
 | 
				
			||||||
 | 
					        <h5 class="modal-title">Edit Transaction</h5>
 | 
				
			||||||
 | 
					        <button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
 | 
				
			||||||
 | 
					            <span aria-hidden="true">×</span>
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <form id="newtransactionform" action="/api/v1/transaction/{{.Transaction.ID}}" method="POST">
 | 
				
			||||||
 | 
					        <div class="modal-body">
 | 
				
			||||||
 | 
					            <input type="hidden" name="account_id" value="{{.Account.ID}}" />
 | 
				
			||||||
 | 
					            <div class="form-group">
 | 
				
			||||||
 | 
					                <label for="category_id">Category</label>
 | 
				
			||||||
 | 
					                <select name="category_id" class="form-control">
 | 
				
			||||||
 | 
					                    <option value="" {{if not $.Transaction.CategoryID.Valid}}selected{{end}}>-- none --</option>
 | 
				
			||||||
 | 
					                    {{range .Categories}}
 | 
				
			||||||
 | 
					                        <option value="{{.ID}}" {{if and $.Transaction.CategoryID.Valid (eq .ID $.Transaction.CategoryID.UUID)}}selected{{end}}>{{.Group}} : {{.Name}}</option>
 | 
				
			||||||
 | 
					                    {{- end}}
 | 
				
			||||||
 | 
					                </select>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="form-group">
 | 
				
			||||||
 | 
					                <label for="payee_id">Payee</label>
 | 
				
			||||||
 | 
					                <select name="payee_id" class="form-control">
 | 
				
			||||||
 | 
					                    <option value="" {{if not $.Transaction.PayeeID.Valid}}selected{{end}}>-- none --</option>
 | 
				
			||||||
 | 
					                    {{range .Payees}}
 | 
				
			||||||
 | 
					                        <option value="{{.ID}}" {{if and $.Transaction.PayeeID.Valid (eq .ID $.Transaction.PayeeID.UUID)}}selected{{end}}>{{.Name}}</option>
 | 
				
			||||||
 | 
					                    {{- end}}
 | 
				
			||||||
 | 
					                </select>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="form-group">
 | 
				
			||||||
 | 
					                <label for="date">Date</label>
 | 
				
			||||||
 | 
					                <input type="date" name="date" class="form-control" value="{{.Transaction.Date.Format "2006-01-02"}}" />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="form-group">
 | 
				
			||||||
 | 
					                <label for="memo">Memo</label>
 | 
				
			||||||
 | 
					                <input type="text" name="memo" class="form-control" value="{{.Transaction.Memo}}" />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="form-group">
 | 
				
			||||||
 | 
					                <label for="amount">Amount</label>
 | 
				
			||||||
 | 
					                <input type="number" name="amount" class="form-control" placeholder="0.00" value="{{printf "%.2f" .Transaction.Amount.GetFloat64}}" />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="modal-footer">
 | 
				
			||||||
 | 
					            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
 | 
				
			||||||
 | 
					            <input type="submit" class="btn btn-primary" name="create" value="Create" class="form-control" />
 | 
				
			||||||
 | 
					            <input type="submit" class="btn btn-danger" name="delete" value="Delete" class="form-control" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{{end}}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user